diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..bdb71b0bf856d2bb914bbe3bc8688d025efc441e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +out +build +.git +.gitignore +Dockerfile +.dockerignore +README.md +.DS_Store +*.pem +.env* +npm-debug.log* +.vercel +coverage +.idea +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4aacd929996c838bc72c04115c93201fb353e2d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 +# MedASR Bench — Hugging Face Docker Space (CPU). Next.js 16 standalone output. +# The Space runtime requires the container to run as uid 1000 and listen on +# app_port (3000, set in README.md front-matter). + +# ---- deps: install full deps for the build ---- +FROM node:22-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ---- builder: produce .next/standalone ---- +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ---- runner: minimal self-contained image ---- +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +# node:alpine already ships a `node` user at uid 1000 — exactly what HF Spaces +# expect — so no custom user is created (uid 1000 is already taken). +# server.js lives at the root of .next/standalone; public/ and .next/static +# are NOT bundled into standalone and must be copied in explicitly. +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public + +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index e215bc4ccf138bbc38ad58ad57e92135484b3c0f..bf599f70010af15060afa50e7eeb66cefe553dc7 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,46 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +--- +title: MedASR Bench +emoji: 🩺 +colorFrom: indigo +colorTo: purple +sdk: docker +app_port: 3000 +pinned: false +license: mit +short_description: Vietnamese medical ASR benchmark, code-switching aware +--- -## Getting Started +# MedASR Bench -First, run the development server: +A public benchmark for **Vietnamese medical speech recognition with English +code-switching**, anchored on the [ViMedCSS](https://huggingface.co/datasets/tensorxt/ViMedCSS) +dataset. Part of the **Clinical Scribe** program. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -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. +This Space is the **display layer** only — a stateless Next.js app that renders +the leaderboard, per-model pages, and methodology. It runs on free CPU; no audio +and no model inference ever happen here. Benchmark numbers are produced by the +offline harness (`benchmark/medasr_eval.py`) and published as versioned, +auditable run manifests. -## Learn More +## Metrics -To learn more about Next.js, take a look at the following resources: +WER · CER · CS-WER (code-switched-term WER) · N-WER · MTR (medical-term +recall), each with bootstrap 95% CIs under a pinned text normalizer. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Running locally -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm install +npm run dev # http://localhost:3000 +``` -## Deploy on Vercel +## Build / deploy -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +This Space is a Docker SDK Space. The image builds the Next.js +[`standalone`](https://nextjs.org/docs/app/api-reference/config/next-config-js/output) +output and serves it with `node server.js` as uid 1000 on port 3000. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +docker build -t medasr-bench . +docker run --rm -p 3000:3000 medasr-bench +``` diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c7057811af28bf9b96632a1eb4004ae7ea5a3e88 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,231 @@ +--- +name: clinicalscribe-taste +description: Anti-slop frontend skill for ClinicalScribe Medical ASR Benchmark. Premium clinical instrument design with ECG phosphor accents, hairline borders, and orchestrated entrance animations. +--- + +# ClinicalScribe Taste Skill + +Anti-slop frontend skill for the ClinicalScribe Medical ASR Benchmark platform. + +--- + +## Design Read + +**"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. + +--- + +## Dial Settings + +Based on the design read: + +- **`DESIGN_VARIANCE: 5`** — Scientific/professional environment, moderate asymmetry is acceptable +- **`MOTION_INTENSITY: 6`** — Orchestrated entrance animations, micro-interactions for interactivity +- **`VISUAL_DENSITY: 7`** — Data-dense benchmark platform, information-rich layouts + +--- + +## Design System Foundation + +### Typography + +- **Display:** `Instrument Serif` — Editorial headings with medical gravitas +- **Body:** `Be Vietnam Pro` — UI text with Vietnamese language support +- **Mono:** `IBM Plex Mono` — Data/numerals with tabular figures + +**Upgrades applied:** +- Reduce all-caps subheaders where possible +- Ensure italic descender clearance with `leading-[1.1]` minimum +- Tighten letter-spacing on large headers + +### Color Calibration + +ClinicalScribe already has excellent color discipline (theme flipped to +LIGHT 2026-06-11 at user request — "light theme, tech savvy, professional"; +palette built in OKLCH, all small-text pairings ≥ 4.5:1): + +| Token | Value | Purpose | +|-------|-------|---------| +| `--bg` | `#f9f9fb` | Cool paper-white background | +| `--surface` | `#ffffff` | Card surfaces | +| `--surface-2` | `#eeeef2` | Recessed panels / code blocks | +| `--text` | `#24242f` | Ink text | +| `--accent` | `#654abb` | Deep violet — oklch(0.50 0.17 290) | +| `--warn` | `#a15e00` | Caution states | +| `--crit` | `#c53637` | Critical/error states | + +**No green anywhere** — the user rejected phosphor green, desaturated teal, +and soft blue; the accent is user-picked violet. Don't drift it back toward +the green–blue band. The theme is LIGHT — don't reintroduce dark surfaces. + +**Preserve:** Single accent color (deep violet), hairline borders, soft +low-alpha shadows (rgba(20,20,45,…) ≤ 0.10). All tints derive from tokens +via `color-mix` — never hardcode accent rgba values. +**Enhance:** Add subtle grain texture, improve hover spotlight effects. + +--- + +## Enhancement Priorities + +### 1. Grain & Texture (High Impact) + +Add subtle noise overlay for depth: + +```css +/* Subtle film grain for tactile quality */ +.grain-overlay::after { + content: ""; + position: fixed; + inset: 0; + z-index: 31; + pointer-events: none; + opacity: 0.03; + 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"); +} +``` + +### 2. Spotlight Border Cards + +Interactive cards that illuminate under cursor: + +```css +.spotlight-card { + position: relative; + overflow: hidden; + transition: border-color 0.3s ease; +} + +.spotlight-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: radial-gradient( + 600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), + rgba(52, 245, 197, 0.15), + transparent 40% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + transition: opacity 0.3s ease; +} + +.spotlight-card:hover::before { + opacity: 1; +} +``` + +### 3. Tactile Button States + +```css +.tactile-button:active { + transform: scale(0.98) translateY(1px); + transition: transform 0.1s ease; +} + +.tactile-button:hover { + background-color: var(--surface-2); +} +``` + +### 4. Spring Physics for Animations + +Replace linear easing with spring curves: + +```css +.spring-in { + animation: spring-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +@keyframes spring-in { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} +``` + +### 5. Stagger Refinements + +```css +/* Improved stagger with spring feel */ +.stagger-spring > * { + animation: stagger-spring 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes stagger-spring { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +--- + +## Layout Discipline + +### Preserved Elements +- Graph-paper micro-grid background (unique, on-brand) +- Scanline overlay effect +- Hairline borders (signature element) +- Editorial hero with Instrument Serif +- ECG phosphor accents (used sparingly) + +### Enhancements +- Increase section breathing room +- Better vertical rhythm with `gap-y-12` or `gap-y-16` +- Asymmetric hero layout with offset stats +- Refined table row hover states + +--- + +## Component Upgrades + +### Card Component +- Add spotlight border on hover +- Subtle inner glow effect +- Refined border-radius (keep `rounded-sm`) + +### Badge Component +- Add subtle shimmer on accent badges +- Refine spacing and typography +- Consistent hover states + +### Leaderboard Table +- Improve row hover transitions +- Add spotlight row highlighting +- Better column breathing room +- Refine sort indicator animations + +--- + +## Accessibility Requirements + +- Honor `prefers-reduced-motion` for all animations +- Maintain WCAG AA contrast ratios +- Keep focus-visible outlines +- Screen reader support for all interactive elements + +--- + +## Pre-Flight Checklist + +- [ ] All animations respect `prefers-reduced-motion` +- [ ] Spotlight effects work on interactive cards +- [ ] Grain texture is subtle (3-5% opacity max) +- [ ] No broken interactions +- [ ] Typography hierarchy is clear +- [ ] Light theme is consistent throughout (no stray dark surfaces) diff --git a/next.config.ts b/next.config.ts index e9ffa3083ad279ecf95fd8eae59cb253e9a539c4..04fc3e86c9b118b8792e97e5d917adb6189f8605 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Emit .next/standalone (server.js + traced node_modules) for a small + // self-contained Docker image — used by the Hugging Face Space runtime. + // Ref: node_modules/next/dist/docs/.../next-config-js/output.md + output: "standalone", }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 5b7f7a234b85b38a06d47fbf0d53d3c06bc0e8f1..f32cdc7989be8ef31a094404927064bf7c7eeff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "platform", "version": "0.1.0", "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.40.0", + "lucide-react": "^1.17.0", "next": "16.2.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.8.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1246,6 +1250,42 @@ "node": ">=12.4.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1253,6 +1293,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1544,6 +1596,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1579,7 +1694,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1595,6 +1710,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -2680,6 +2801,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2733,9 +2863,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2815,6 +3066,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3101,6 +3358,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3530,6 +3797,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3671,6 +3944,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3986,6 +4286,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4028,6 +4338,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4929,6 +5248,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4996,6 +5324,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5521,9 +5864,76 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5568,6 +5978,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6161,6 +6577,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6501,6 +6923,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f1320f705f124f268e995a037569181104d5114e..169069bab6e9688701efdc336e205468365691c2 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,17 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "sync-results": "node scripts/sync-results.mjs" }, "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.40.0", + "lucide-react": "^1.17.0", "next": "16.2.9", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "recharts": "^3.8.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/scripts/sync-results.mjs b/scripts/sync-results.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0f76591c98303c7a5fb5df0d33e5237ad43f694d --- /dev/null +++ b/scripts/sync-results.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * sync-results.mjs — pull platform run outputs into src/data/results.json. + * + * Reads, for each known model and each split (test|hard): + * ../benchmark/results///metrics.json + * ../benchmark/results///run_manifest.json (optional, logged) + * + * metrics.json keys: wer, wer_ci95 [lo,hi], cer, cs_wer, cs_wer_ci95, + * n_wer, mtr_exact, mtr_fuzzy, rtfx_infer. + * + * Upserts the matching slug's metrics into the vimedcss entry of + * src/data/results.json (shape: { datasets: [{ info, models }] }) and sets + * provenance to "platform-verified". All other rows and fields are preserved. + * + * Usage: npm run sync-results + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLATFORM_ROOT = resolve(__dirname, ".."); +const RESULTS_ROOT = resolve(PLATFORM_ROOT, "..", "benchmark", "results"); +const DATA_FILE = join(PLATFORM_ROOT, "src", "data", "results.json"); + +/** benchmark results dir name → leaderboard slug */ +const MODEL_MAP = { + zipformer: "zipformer-30m", + qwen3asr: "qwen3-asr-0.6b", + gemini: "gemini-3.5-flash", + deepgram: "deepgram-nova-3", +}; + +const SPLITS = ["test", "hard"]; + +/** Platform runs land on the anchor dataset's leaderboard. */ +const TARGET_DATASET_ID = "vimedcss"; + +/** metrics.json key → SplitMetrics field */ +function toSplitMetrics(raw) { + const out = {}; + if (typeof raw.wer === "number") out.wer = raw.wer; + if (Array.isArray(raw.wer_ci95)) out.werCi = raw.wer_ci95; + if (typeof raw.cer === "number") out.cer = raw.cer; + if (typeof raw.cs_wer === "number") out.csWer = raw.cs_wer; + if (Array.isArray(raw.cs_wer_ci95)) out.csWerCi = raw.cs_wer_ci95; + if (typeof raw.n_wer === "number") out.nWer = raw.n_wer; + if (typeof raw.mtr_exact === "number") out.mtrExact = raw.mtr_exact; + if (typeof raw.mtr_fuzzy === "number") out.mtrFuzzy = raw.mtr_fuzzy; + if (typeof raw.rtfx_infer === "number") out.rtfx = raw.rtfx_infer; + return out; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function main() { + if (!existsSync(RESULTS_ROOT)) { + console.log(`[sync-results] no results dir at ${RESULTS_ROOT} — nothing to do`); + return; + } + + const data = readJson(DATA_FILE); + const entry = (data.datasets ?? []).find( + (d) => d.info?.id === TARGET_DATASET_ID, + ); + if (!entry) { + console.error( + `[sync-results] dataset "${TARGET_DATASET_ID}" not found in ${DATA_FILE} — aborting`, + ); + process.exitCode = 1; + return; + } + let updates = 0; + + for (const [dir, slug] of Object.entries(MODEL_MAP)) { + const row = entry.models.find((m) => m.slug === slug); + if (!row) { + console.warn(`[sync-results] slug ${slug} not found in results.json — skipped`); + continue; + } + + for (const split of SPLITS) { + const metricsPath = join(RESULTS_ROOT, dir, split, "metrics.json"); + if (!existsSync(metricsPath)) continue; + + let raw; + try { + raw = readJson(metricsPath); + } catch (err) { + console.warn(`[sync-results] unreadable ${metricsPath}: ${err.message} — skipped`); + continue; + } + + const manifestPath = join(RESULTS_ROOT, dir, split, "run_manifest.json"); + let manifest; + if (existsSync(manifestPath)) { + try { + manifest = readJson(manifestPath); + console.log( + `[sync-results] ${slug}/${split} manifest:`, + JSON.stringify(manifest), + ); + } catch { + console.warn(`[sync-results] unreadable manifest at ${manifestPath}`); + } + } + + row.metrics = row.metrics ?? {}; + row.metrics[split] = toSplitMetrics(raw); + /* RTFx: the manifest computes audio_s / infer_s at run time and is the + authoritative timing record; metrics.json only snapshots it at score + time and can go stale across re-runs (observed on qwen3asr). API + runners report infer_total=0 -> manifest rtfx_infer null, so RTFx + stays absent (network latency ≠ local-hardware throughput). */ + if (typeof manifest?.rtfx_infer === "number") { + row.metrics[split].rtfx = manifest.rtfx_infer; + } + /* Provenance: locally-run models are platform-verified (pinned + revision, our hardware, SOTA-eligible). Commercial APIs run through + the same harness but on the vendor's service with no pinned revision, + so they are recorded as a date-stamped "api-snapshot" instead. */ + const isApi = row.category === "api-intl" || row.category === "api-vn"; + row.provenance = isApi ? "api-snapshot" : "platform-verified"; + updates += 1; + console.log(`[sync-results] upserted ${slug}/${split}`); + } + } + + if (updates === 0) { + console.log("[sync-results] no metrics found — results.json untouched"); + return; + } + + writeFileSync(DATA_FILE, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + console.log(`[sync-results] wrote ${updates} split result(s) to ${DATA_FILE}`); +} + +main(); diff --git a/src/app/compare/loading.tsx b/src/app/compare/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d306532b53593770ddf9c271f958224904cee5ac --- /dev/null +++ b/src/app/compare/loading.tsx @@ -0,0 +1,100 @@ +import { + Skeleton, + SkeletonTable, + SkeletonText, +} from "@/components/ui/LoadingSkeleton"; + +/* Route-level loading state for /compare — mirrors the page's structure + (heading + dataset chips, Pareto canvas, head-to-head card, efficiency + canvas) so content streams in without layout shift. Server-safe: all + shimmer comes from the .skeleton class, which collapses to a static + block under prefers-reduced-motion. */ + +function HeadingSkeleton({ titleWidth = 320 }: { titleWidth?: number }) { + return ( +
+ + + +
+ ); +} + +function ChartCardSkeleton({ height }: { height: number }) { + return ( +
+ {/* control row: legend chips left, toggle right */} +
+
+ {[72, 96, 64, 88].map((w, i) => ( + + ))} +
+ +
+ {/* chart canvas */} + + {/* caption */} +
+ +
+
+ ); +} + +export default function CompareLoading() { + return ( +
+ {/* 02 · Compare heading + dataset-scope chips */} +
+ +
+ + {[88, 76, 112].map((w, i) => ( + + ))} +
+
+ + {/* pulse line strip */} + + + {/* Pareto frontier card */} + + + {/* 02.1 · Head-to-head */} + +
+ {/* three model selectors */} +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))} +
+ {/* delta table */} + + {/* profile bars */} +
+ {[0, 1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+
+ + {/* 02.2 · Efficiency */} + + +
+ ); +} diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fc9a8e305dc5f831f64936a77f313929df79126 --- /dev/null +++ b/src/app/compare/page.tsx @@ -0,0 +1,101 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import DatasetChips from "@/components/ui/DatasetChips"; +import { Skeleton, SkeletonTable } from "@/components/ui/LoadingSkeleton"; +import PulseLine from "@/components/ui/PulseLine"; +import ScrollReveal from "@/components/ui/ScrollReveal"; +import SectionHeading from "@/components/ui/SectionHeading"; +import EfficiencySection from "@/components/compare/EfficiencySection"; +import HeadToHead from "@/components/compare/HeadToHead"; +import ParetoSection from "@/components/compare/ParetoSection"; +import { + getDataset, + getDatasets, + getModels, + NORMALIZER_VERSION, +} from "@/lib/data"; + +export const metadata: Metadata = { title: "Compare" }; + +function HeadToHeadFallback() { + return ( +
+
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))} +
+ +
+ ); +} + +export default function ComparePage() { + const dataset = getDataset(); // anchor: ViMedCSS, the first live dataset + const datasets = getDatasets(); + const models = getModels(dataset.id); + + return ( +
+ {/* above the fold: spring-staggered stack */} +
+
+ + Overall WER against CS-WER — errors inside code-switched spans + — scoped to {dataset.name}, the hub’s first live dataset. + Lower-left wins. Bootstrap 95%{" "} + CIs; normalizer{" "} + {NORMALIZER_VERSION}; WER is only + comparable within a normalizer version. + + } + /> +
+ + Dataset scope + + +
+
+ + +
+ + + + + + }> + + + + + + + + + + +
+ ); +} diff --git a/src/app/datasets/[slug]/page.tsx b/src/app/datasets/[slug]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d342b55624130cb115fc88cb4f4a8b2a16523018 --- /dev/null +++ b/src/app/datasets/[slug]/page.tsx @@ -0,0 +1,548 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowUpRight, ExternalLink } from "lucide-react"; +import Badge from "@/components/ui/Badge"; +import DatasetChips from "@/components/ui/DatasetChips"; +import ScrollReveal from "@/components/ui/ScrollReveal"; +import SectionHeading from "@/components/ui/SectionHeading"; +import CautionCallout from "@/components/docs/CautionCallout"; +import OnboardingCriteria from "@/components/docs/OnboardingCriteria"; +import PipelineSteps from "@/components/docs/PipelineSteps"; +import RoadmapStrip, { type RoadmapItem } from "@/components/docs/RoadmapStrip"; +import SplitsTable from "@/components/docs/SplitsTable"; +import StatTile from "@/components/docs/StatTile"; +import TopicBars from "@/components/docs/TopicBars"; +import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data"; +import type { DatasetInfo } from "@/lib/types"; + +interface Props { + params: Promise<{ slug: string }>; +} + +export function generateStaticParams() { + return getDatasets().map((d) => ({ slug: d.id })); +} + +/* The registry is closed (static JSON) — unknown slugs must be a real + HTTP 404, not a streamed 200 with not-found content. */ +export const dynamicParams = false; + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const dataset = getDatasets().find((d) => d.id === slug); + if (!dataset) return { title: "Dataset" }; + return { + title: + dataset.status === "live" + ? `${dataset.name} dataset` + : `${dataset.name} — onboarding`, + description: dataset.tagline, + }; +} + +/* ------------------------------------------------------------------ */ +/* ViMedCSS editorial material — corpus-specific scholarship that has */ +/* no home in the data API. Scoped to the vimedcss id; other live */ +/* datasets simply skip these sections. */ +/* ------------------------------------------------------------------ */ + +const VIMEDCSS_TOPICS = [ + { label: "Medical Sciences", vi: "Khoa học y khoa", hours: 16.3 }, + { label: "Pathology & Pathogens", vi: "Bệnh học & tác nhân gây bệnh", hours: 10.3 }, + { label: "Treatments", vi: "Điều trị", hours: 3.8 }, + { label: "Nutrition", vi: "Dinh dưỡng", hours: 2.1 }, + { label: "Diagnostics", vi: "Chẩn đoán", hours: 2.0 }, +] as const; + +const VIMEDCSS_PIPELINE = [ + { + title: "YouTube crawl", + stat: "700+ hours", + detail: + "Vietnamese medical channels crawled and filtered down to clean lecture-style audio, then segmented into 2–21 second utterances.", + }, + { + title: "Meddict lexicon match", + stat: "64,232 entries", + detail: + "Candidate English medical terms located against the 64,232-entry Meddict bilingual medical lexicon to anchor code-switched spans.", + }, + { + title: "LLM label seeding", + stat: "Gemini 2.5 Pro", + detail: + "First-pass transcripts and span labels drafted by Gemini 2.5 Pro — the origin of the permanent contamination flag on Gemini-family rows.", + }, + { + title: "Levenshtein canonicalization", + stat: "889 canonical terms", + detail: + "Surface variants of the same term collapsed to canonical forms by edit-distance matching, yielding 889 distinct code-switched medical terms.", + }, + { + title: "Human verification", + stat: "κ = 0.65", + detail: + "Annotators verified every label; inter-annotator agreement κ = 0.65. Verified — but Gemini-seeded. See the caution below.", + }, +]; + +/** Accent-tinted chip marking a code-switched English medical term. */ +function CS({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Shared bits */ +/* ------------------------------------------------------------------ */ + +const chipLink = + "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"; + +const actionLink = + "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"; + +const ROADMAP_ROLE: Record = { + [ANCHOR_DATASET_ID]: "live", + vietmed: "next", + "private-clinic": "north-star", +}; + +function buildRoadmap(): RoadmapItem[] { + return getDatasets().map((d) => ({ + name: d.name, + status: + d.status === "live" ? "live" : (ROADMAP_ROLE[d.id] ?? "next"), + stat: + d.status === "live" && d.hours !== undefined + ? `${fmt(d.hours)} h` + : undefined, + detail: d.tagline, + })); +} + +function RegistryStrip({ activeId }: { activeId: string }) { + return ( +
+ + + All datasets + + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Live dataset profile */ +/* ------------------------------------------------------------------ */ + +function LiveProfile({ dataset }: { dataset: DatasetInfo }) { + const isViMedCSS = dataset.id === "vimedcss"; + const systems = getModels(dataset.id).length; + + /* sub-section numbering stays dense even when editorial sections are + absent on future live datasets */ + let n = 0; + const sec = () => `03.${++n}`; + + return ( +
+ {/* 1 · Header */} +
+ +
+

+ 03 · Dataset profile +

+

+ {dataset.name} + {dataset.fullName && ( + + {dataset.fullName} + + )} +

+

+ {dataset.tagline} + {dataset.id === ANCHOR_DATASET_ID && ( + <> The anchor benchmark of MedASR Bench — its first live dataset. + )} +

+
+ + + Live · {systems} systems + + {dataset.license && ( + + {dataset.license} + + )} + {dataset.arxiv && ( + + arXiv:{dataset.arxiv} + + + )} + {dataset.hf && ( + + HF · {dataset.hf} + + + )} +
+
+
+ + {/* 2 · Stat strip */} +
+ {dataset.hours !== undefined && ( + + )} + {dataset.utterances !== undefined && ( + + )} + {dataset.csTerms !== undefined && ( + + )} + {isViMedCSS && } +
+ + {/* Specimen — ViMedCSS editorial */} + {isViMedCSS && ( +
+ +
+
+

+ Bệnh nhân có tiền sử tăng huyết áp, chỉ định siêu âm{" "} + Doppler động mạch cảnh. +

+

+ Kết quả MRI cho thấy tổn thương chất trắng lan tỏa, cần + theo dõi multiple sclerosis. +

+
+
+ Lecture-style utterances, 2–21 s ·{" "} + highlighted = code-switched + English medical term + {dataset.csTerms !== undefined && <> ({dataset.csTerms} distinct)} · + scored by CS-WER +
+
+
+ )} + + {/* Splits — any live dataset with frozen splits */} + {dataset.splits && ( +
+ + +
+ )} + + {/* Topics — ViMedCSS editorial */} + {isViMedCSS && ( + +
+ + +
+
+ )} + + {/* Construction — ViMedCSS editorial */} + {isViMedCSS && ( + +
+ + +
+
+ )} + + {/* Validity — ViMedCSS editorial */} + {isViMedCSS && ( + +
+ +
+ +

+ Transcripts and code-switch spans were first drafted by Gemini + 2.5 Pro, then human-verified at{" "} + κ = 0.65. Gemini-family + models are therefore structurally advantaged on this benchmark + and carry a{" "} + + permanent contamination flag + {" "} + on every ViMedCSS leaderboard, regardless of score. +

+

+ Test labels are public, so provenance declarations are + honor-system today. A private canary subset is on the roadmap + to detect test-set training. +

+
+ +

+ ViMedCSS is lecture-style YouTube audio: clean microphones, + composed delivery, one speaker. The production setting — + noisy, two-speaker clinic conversation — is materially harder. +

+

+ Never read a ViMedCSS score as progress against the production{" "} + WER < 15% deployment + gate. That gate is scored on conversational clinic data (Track + F, roadmap). +

+
+
+
+
+ )} + + {/* Hub roadmap — generic, derived from the registry */} + +
+ + +
+ + + All datasets + + + Onboarding criteria + + +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Coming-soon teaser */ +/* ------------------------------------------------------------------ */ + +const LAUNCH_STEPS = [ + { + title: "Profile published", + detail: + "Provenance, license, and split inventory go live on this page — frozen before any system is scored.", + }, + { + title: "Baselines re-run", + detail: + "The platform harness runs the existing leaderboard systems against the new corpus, manifests and all.", + }, + { + title: "Leaderboard opens", + detail: + "Six metrics with bootstrap CIs and provenance chips from day one — comparable with every other dataset on the hub.", + }, +]; + +function ComingSoonProfile({ dataset }: { dataset: DatasetInfo }) { + return ( +
+ {/* Header */} +
+ +
+

+ 03 · Dataset profile +

+

+ {dataset.name} +

+

+ {dataset.tagline} +

+
+ Onboarding + {dataset.eta && ( + + ETA {dataset.eta} + + )} + {dataset.license && ( + + {dataset.license} + + )} +
+
+
+ + {/* What's known today */} +
+ +
+ {[ + { dt: "Status", dd: "Onboarding — not yet runnable" }, + { dt: "Source", dd: dataset.source ?? "To be announced" }, + { + dt: "Leaderboard", + dd: "Opens after the first platform-verified run", + }, + ].map((row) => ( +
+
+ {row.dt} +
+
{row.dd}
+
+ ))} +
+
+ + {/* Onboarding criteria */} + +
+ + + + Full criteria & versioning policy + + +
+
+ + {/* Launch sequence */} + +
+ +
    + {LAUNCH_STEPS.map((step, i) => ( +
  1. + + {String(i + 1).padStart(2, "0")} + +

    {step.title}

    +

    + {step.detail} +

    +
  2. + ))} +
+
+ + + All datasets + + + See the live anchor dataset + +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Route */ +/* ------------------------------------------------------------------ */ + +export default async function DatasetPage({ params }: Props) { + const { slug } = await params; + const dataset = getDatasets().find((d) => d.id === slug); + if (!dataset) notFound(); + + return dataset.status === "live" ? ( + + ) : ( + + ); +} diff --git a/src/app/datasets/loading.tsx b/src/app/datasets/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..191c4f386d0de5f147272c1aabb87fc5d6d5b646 --- /dev/null +++ b/src/app/datasets/loading.tsx @@ -0,0 +1,43 @@ +import { + Skeleton, + SkeletonCard, + SkeletonStats, +} from "@/components/ui/LoadingSkeleton"; + +/** Route-level skeleton for /datasets and its nested dataset profiles. */ +export default function Loading() { + return ( +
+ {/* header */} +
+ + + +
+ + + +
+
+ + {/* registry grid */} +
+
+
+ + +
+ + + + +
+ + +
+ +
+
+
+ ); +} diff --git a/src/app/datasets/page.tsx b/src/app/datasets/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ba735f7b5bb6f480b5309e07f6941d73b7580332 --- /dev/null +++ b/src/app/datasets/page.tsx @@ -0,0 +1,274 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, ArrowUpRight, ExternalLink } from "lucide-react"; +import Badge from "@/components/ui/Badge"; +import Card from "@/components/ui/Card"; +import DatasetChips from "@/components/ui/DatasetChips"; +import SectionHeading from "@/components/ui/SectionHeading"; +import { ONBOARDING_CRITERIA } from "@/components/docs/OnboardingCriteria"; +import SplitMiniBar from "@/components/docs/SplitMiniBar"; +import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data"; +import type { DatasetInfo } from "@/lib/types"; + +export const metadata: Metadata = { + title: "Datasets", + description: + "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.", +}; + +const chipLink = + "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"; + +const actionPrimary = + "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"; + +const actionSecondary = + "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"; + +/** Rich profile card for a live dataset — stats, split bars, links. */ +function LiveDatasetCard({ dataset }: { dataset: DatasetInfo }) { + const systems = getModels(dataset.id).length; + + return ( + +
+
+ + + Live + + {dataset.id === ANCHOR_DATASET_ID && ( + Anchor dataset + )} + {dataset.license && ( + + {dataset.license} + + )} +
+ +
+ {/* identity */} +
+

+ {dataset.name} + {dataset.fullName && ( + + {dataset.fullName} + + )} +

+

+ {dataset.tagline} +

+ {dataset.source && ( +

+ Source · {dataset.source} +

+ )} +
+ {dataset.arxiv && ( + + arXiv:{dataset.arxiv} + + + )} + {dataset.hf && ( + + HF · {dataset.hf} + + + )} +
+
+ + Dataset profile + + + + Leaderboard · {systems} systems + + +
+
+ + {/* instrument readout */} +
+
+ {dataset.hours !== undefined && ( +
+
+ Audio +
+
+ {fmt(dataset.hours)} + h +
+
+ )} + {dataset.utterances !== undefined && ( +
+
+ Utterances +
+
+ {dataset.utterances.toLocaleString("en-US")} +
+
+ )} + {dataset.csTerms !== undefined && ( +
+
+ CS terms +
+
+ {dataset.csTerms.toLocaleString("en-US")} +
+
+ )} +
+ {dataset.splits && ( +
+

+ Frozen splits · share of audio +

+ +
+ )} +
+
+
+
+ ); +} + +/** Muted teaser card for an onboarding dataset. */ +function ComingSoonCard({ dataset }: { dataset: DatasetInfo }) { + return ( +
+
+ Onboarding + + {dataset.eta ? `ETA ${dataset.eta}` : "soon"} + +
+

+ {dataset.name} +

+

{dataset.tagline}

+ {dataset.source && ( +

+ Source · {dataset.source} +

+ )} + + What onboarding involves + + +
+ ); +} + +export default function DatasetsPage() { + const datasets = getDatasets(); + const live = datasets.filter((d) => d.status === "live"); + const comingSoon = datasets.filter((d) => d.status === "coming-soon"); + + return ( +
+ {/* 1 · Header */} +
+ + MedASR Bench is a benchmark hub for Vietnamese medical ASR. Every + dataset mounts into the same harness — same normalizer, same six + metrics, same provenance rules — so scores stay comparable across + corpora. {live.length} live + today, {comingSoon.length}{" "} + onboarding. + + } + /> + +
+ + {/* 2 · Bento registry */} +
+ {live.map((dataset) => ( +
+ +
+ ))} + {comingSoon.map((dataset, i) => ( +
+ +
+ ))} + + {/* 3 · Onboarding CTA — glass treatment */} +
+ +
+
+
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41ecee5ec435200fe7cba2bde4107f823774..3606372f58a9f37a91da2378bfa231c94de445da 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,823 @@ @import "tailwindcss"; +/* ------------------------------------------------------------------ */ +/* MedASR Bench — "Clinical Instrument" token system */ +/* ------------------------------------------------------------------ */ + :root { - --background: #ffffff; - --foreground: #171717; + color-scheme: light; + + /* Palette — light "precision instrument": cool paper-white surfaces, ink + text, deep violet accent (user-picked hue; deepened for light-mode + contrast: oklch(0.50 0.17 290) = 6.1:1 on --bg). Built in OKLCH; all + small-text pairings clear WCAG 4.5:1. Saturated hues are reserved for + meaning (accent/warn/crit). NO green — repeatedly rejected by the user. */ + --bg: #f9f9fb; + --surface: #ffffff; + --surface-2: #eeeef2; + --line: rgba(36, 36, 60, 0.13); + --text: #24242f; + --muted: #62626f; + --accent: #654abb; /* deep violet — sparingly */ + --warn: #a15e00; /* contamination / caution only */ + --crit: #c53637; /* critical / error only */ + + /* Derived tints — single-source: everything mixes from the tokens above */ + --accent-dim: color-mix(in srgb, var(--accent) 12%, transparent); + --warn-dim: color-mix(in srgb, var(--warn) 12%, transparent); + --crit-dim: color-mix(in srgb, var(--crit) 10%, transparent); + --grid-ink: rgba(36, 36, 80, 0.045); + --grid-ink-major: rgba(36, 36, 80, 0.065); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + /* Colors → Tailwind utilities (bg-bg, text-muted, border-line, …) */ + --color-bg: var(--bg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-line: var(--line); + --color-text: var(--text); + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-warn: var(--warn); + --color-crit: var(--crit); + --color-accent-dim: var(--accent-dim); + --color-warn-dim: var(--warn-dim); + --color-crit-dim: var(--crit-dim); + + /* Fonts → font-display / font-body / font-mono utilities. + The var() chain resolves to next/font variables set on . */ + --font-display: var(--font-display), "Instrument Serif", Georgia, serif; + --font-body: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif; + --font-sans: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif; + --font-mono: var(--font-mono), "IBM Plex Mono", ui-monospace, SFMono-Regular, + monospace; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +/* ------------------------------------------------------------------ */ +/* Base + atmosphere: graph-paper micro-grid + scanline layer */ +/* ------------------------------------------------------------------ */ + +html { + background-color: var(--bg); } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + /* solid color lives on ; body stays transparent so the fixed + negative-z ambient glow layers (AmbientEffects) shine through the grid */ + background-color: transparent; + color: var(--text); + font-family: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif; + /* graph-paper grid: 140px major rhythm over the 28px minor micro-grid */ + background-image: linear-gradient(var(--grid-ink-major) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-ink-major) 1px, transparent 1px), + linear-gradient(var(--grid-ink) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-ink) 1px, transparent 1px); + background-size: + 140px 140px, + 140px 140px, + 28px 28px, + 28px 28px; + -webkit-font-smoothing: antialiased; +} + +/* ------------------------------------------------------------------ */ +/* Grain texture — subtle film noise for tactile depth */ +/* ------------------------------------------------------------------ */ + +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 29; + pointer-events: none; + opacity: 0.02; + 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"); +} + +/* scanline veil — fixed, non-interactive, layered above grain */ +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 30; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + color-mix(in srgb, var(--text) 1.2%, transparent) 0px, + color-mix(in srgb, var(--text) 1.2%, transparent) 1px, + transparent 1px, + transparent 3px + ); +} + +::selection { + background: var(--accent-dim); + color: var(--text); +} + +/* ------------------------------------------------------------------ */ +/* Ambient light — fixed phosphor glow washes behind all content */ +/* (rendered by components/ui/AmbientEffects; GPU-cheap transform drift) */ +/* ------------------------------------------------------------------ */ + +@keyframes ambient-drift-a { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(4%, 3%, 0) scale(1.08); + } +} + +@keyframes ambient-drift-b { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(-3%, -4%, 0) scale(1.06); + } +} + +.ambient-glow { + position: absolute; + pointer-events: none; + will-change: transform; +} + +/* top-center wash — the room's key light */ +.ambient-glow-top { + top: -32vh; + left: -12vw; + right: -12vw; + height: 78vh; + background: radial-gradient( + ellipse 55% 55% at 50% 45%, + color-mix(in srgb, var(--accent) 9%, transparent), + transparent 70% + ); + animation: ambient-drift-a 70s ease-in-out infinite; +} + +/* lower-right fill — faint counter-glow */ +.ambient-glow-corner { + right: -22vw; + bottom: -30vh; + width: 72vw; + height: 75vh; + background: radial-gradient( + ellipse 50% 50% at 55% 55%, + color-mix(in srgb, var(--accent) 5.5%, transparent), + transparent 70% + ); + animation: ambient-drift-b 90s ease-in-out infinite; +} + +/* ------------------------------------------------------------------ */ +/* Glass morphism — translucent surfaces tuned to the lifted palette */ +/* ------------------------------------------------------------------ */ + +/* layered as components so Tailwind utilities (bg-surface/55 …) can + override the surface opacity per-instance */ +@layer components { + /* veil only: translucent surface + blur, no border (compose freely) */ + .glass-veil { + background: color-mix(in srgb, var(--surface) 66%, transparent); + -webkit-backdrop-filter: blur(16px) saturate(1.2); + backdrop-filter: blur(16px) saturate(1.2); + } + + /* full glass panel: veil + hairline + machined top-edge relief */ + .glass { + background: color-mix(in srgb, var(--surface) 72%, transparent); + -webkit-backdrop-filter: blur(14px) saturate(1.2); + backdrop-filter: blur(14px) saturate(1.2); + border: 1px solid var(--line); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent); + } +} + +/* ------------------------------------------------------------------ */ +/* Hairline utilities (1px --line rules, the only "shadows" we use) */ +/* ------------------------------------------------------------------ */ + +.hairline { + border: 1px solid var(--line); + /* machined top-edge relief — the only "shadow" beyond hairlines */ + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent); +} +.hairline-t { + border-top: 1px solid var(--line); +} +.hairline-b { + border-bottom: 1px solid var(--line); +} +.hairline-l { + border-left: 1px solid var(--line); +} +.hairline-r { + border-right: 1px solid var(--line); +} + +/* ------------------------------------------------------------------ */ +/* Spring animations — physics-based motion */ +/* ------------------------------------------------------------------ */ + +@keyframes spring-in { + from { + opacity: 0; + transform: scale(0.95) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes spring-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes soft-appear { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Spring-based entrance animations */ +.spring-in { + animation: spring-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +.spring-up { + animation: spring-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.soft-appear { + animation: soft-appear 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* Improved stagger with spring feel */ +.stagger-spring > * { + animation: spring-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; +} +.stagger-spring > :nth-child(1) { animation-delay: 0ms; } +.stagger-spring > :nth-child(2) { animation-delay: 60ms; } +.stagger-spring > :nth-child(3) { animation-delay: 120ms; } +.stagger-spring > :nth-child(4) { animation-delay: 180ms; } +.stagger-spring > :nth-child(5) { animation-delay: 240ms; } +.stagger-spring > :nth-child(6) { animation-delay: 300ms; } +.stagger-spring > :nth-child(7) { animation-delay: 360ms; } +.stagger-spring > :nth-child(8) { animation-delay: 420ms; } + +/* ------------------------------------------------------------------ */ +/* Spotlight border effect — card illumination on hover */ +/* ------------------------------------------------------------------ */ + +.spotlight-card { + position: relative; + isolation: isolate; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.spotlight-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient( + 500px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), + color-mix(in srgb, var(--accent) 12%, transparent), + transparent 40% + ); + opacity: 0; + transition: opacity 0.4s ease; + z-index: -1; + pointer-events: none; +} + +.spotlight-card:hover { + border-color: color-mix(in srgb, var(--accent) 25%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--accent) 8%, transparent); +} + +.spotlight-card:hover::before { + opacity: 1; +} + +/* Spotlight row for tables. + IMPORTANT: no ::before/::after on this class — it sits on , and a + generated box on a table row gets wrapped in an ANONYMOUS CELL, silently + shifting every real cell one column right of the headers. The hover + sweep is painted as the row's own background instead. */ +.spotlight-row { + transition: background-color 0.2s ease; +} + +.spotlight-row:hover { + background-color: var(--surface-2); + background-image: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--accent) 4%, transparent) 50%, + transparent 100% + ); +} + +/* ------------------------------------------------------------------ */ +/* Tactile button states */ +/* ------------------------------------------------------------------ */ + +.tactile { + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), + background-color 0.2s ease, + border-color 0.2s ease; +} + +.tactile:active { + transform: scale(0.975) translateY(1px); +} + +.tactile:hover:not(:disabled) { + background-color: var(--surface-2); +} + +/* Interactive elements with subtle lift */ +.hover-lift { + transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.2s ease; +} + +.hover-lift:hover { + transform: translateY(-2px); + box-shadow: + 0 4px 14px rgba(20, 20, 45, 0.1), + inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent); +} + +/* ------------------------------------------------------------------ */ +/* Accent glow effects */ +/* ------------------------------------------------------------------ */ + +.glow-accent-subtle { + box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 10%, transparent); +} + +/* ------------------------------------------------------------------ */ +/* Edge accent bar — vertical phosphor line */ +/* ------------------------------------------------------------------ */ + +.edge-accent { + position: relative; +} + +.edge-accent::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient( + to bottom, + transparent, + var(--accent) 20%, + var(--accent) 80%, + transparent + ); + opacity: 0.6; +} + +/* Mono numerals everywhere data lives */ +.num { + font-family: var(--font-mono), "IBM Plex Mono", ui-monospace, monospace; + font-variant-numeric: tabular-nums; +} +.tabular { + font-variant-numeric: tabular-nums; +} + +/* ------------------------------------------------------------------ */ +/* Keyframes */ +/* ------------------------------------------------------------------ */ + +@keyframes rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* pair with pathLength={1} + stroke-dasharray: 1 on an SVG path */ +@keyframes drawLine { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +/* ambient ECG sweep: a short bright tracer cycling over the dim base path */ +@keyframes sweep { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes shimmer { + from { + background-position: -200% 0; + } + to { + background-position: 200% 0; + } +} + +@keyframes pulseDot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.35; + transform: scale(0.78); + } +} + +@keyframes scaleInX { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +/* ------------------------------------------------------------------ */ +/* Motion helpers — one orchestrated load per route */ +/* ------------------------------------------------------------------ */ + +/* stagger container: children rise with 55ms steps */ +.stagger > * { + animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} +.stagger > :nth-child(1) { + animation-delay: 0ms; +} +.stagger > :nth-child(2) { + animation-delay: 55ms; +} +.stagger > :nth-child(3) { + animation-delay: 110ms; +} +.stagger > :nth-child(4) { + animation-delay: 165ms; +} +.stagger > :nth-child(5) { + animation-delay: 220ms; +} +.stagger > :nth-child(6) { + animation-delay: 275ms; +} +.stagger > :nth-child(7) { + animation-delay: 330ms; +} +.stagger > :nth-child(8) { + animation-delay: 385ms; +} +.stagger > :nth-child(9) { + animation-delay: 440ms; +} +.stagger > :nth-child(10) { + animation-delay: 495ms; +} +.stagger > :nth-child(11) { + animation-delay: 550ms; +} +.stagger > :nth-child(12) { + animation-delay: 605ms; +} + +/* manual stagger steps for non-sibling layouts */ +.delay-1 { + animation-delay: 55ms; +} +.delay-2 { + animation-delay: 110ms; +} +.delay-3 { + animation-delay: 165ms; +} +.delay-4 { + animation-delay: 220ms; +} + +/* skeleton block: surface placeholder swept by the shimmer keyframes */ +.skeleton { + position: relative; + overflow: hidden; + border-radius: 2px; + background-color: var(--surface-2); + background-image: linear-gradient( + 100deg, + transparent 40%, + color-mix(in srgb, var(--text) 7%, transparent) 50%, + transparent 60% + ); + background-size: 200% 100%; + animation: shimmer 1.8s linear infinite; +} + +/* shimmering surface for status=running chips */ +.chip-shimmer { + background-image: linear-gradient( + 100deg, + color-mix(in srgb, var(--accent) 7%, transparent) 40%, + color-mix(in srgb, var(--accent) 22%, transparent) 50%, + color-mix(in srgb, var(--accent) 7%, transparent) 60% + ); + background-size: 200% 100%; + animation: shimmer 2.4s linear infinite; +} + +.pulse-dot { + animation: pulseDot 1.4s ease-in-out infinite; +} + +.draw-line { + stroke-dasharray: 1; + animation: drawLine 1.4s cubic-bezier(0.4, 0, 0.2, 1) 0.15s both; +} + +/* pair with pathLength={1}: 6%-long phosphor tracer, ~7s per pass */ +.pulse-sweep { + stroke-dasharray: 0.06 0.94; + animation: sweep 7s linear infinite; +} + +/* CI whisker draw-in: the micro-bar sweeps open left → right once */ +.ci-draw { + transform-origin: left center; + animation: scaleInX 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +/* NOTE: scroll-driven entrances (animation-timeline: view()) were tried + and removed — with fill-mode both, content whose timeline binds to the + wrong scroll container (e.g. rows inside overflow-x-auto) can be stuck + at opacity 0 forever. Entrances are load-orchestrated only. */ + +/* ------------------------------------------------------------------ */ +/* Scroll-triggered animations (Intersection Observer based) */ +/* ------------------------------------------------------------------ */ + +/* Base scroll animation classes */ +.scroll-animate { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1); +} + +.scroll-animate.in-view { + opacity: 1; + transform: translateY(0); +} + +/* Scroll scale animation */ +.scroll-scale { + opacity: 0; + transform: scale(0.95); + transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.scroll-scale.in-view { + opacity: 1; + transform: scale(1); +} + +/* Scroll slide animations */ +.scroll-slide-left { + opacity: 0; + transform: translateX(-24px); + transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.scroll-slide-left.in-view { + opacity: 1; + transform: translateX(0); +} + +.scroll-slide-right { + opacity: 0; + transform: translateX(24px); + transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.scroll-slide-right.in-view { + opacity: 1; + transform: translateX(0); +} + +.scroll-slide-down { + opacity: 0; + transform: translateY(-24px); + transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.scroll-slide-down.in-view { + opacity: 1; + transform: translateY(0); +} + +/* Scroll fade animation */ +.scroll-fade { + opacity: 0; + transition: opacity 0.4s ease-out; +} + +.scroll-fade.in-view { + opacity: 1; +} + +/* Stagger children on scroll */ +.scroll-stagger > * { + opacity: 0; + transform: translateY(16px); + transition: opacity 0.4s ease-out, + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.scroll-stagger.in-view > *:nth-child(1) { opacity: 1; transform: translateY(0); transition-delay: 0ms; } +.scroll-stagger.in-view > *:nth-child(2) { opacity: 1; transform: translateY(0); transition-delay: 60ms; } +.scroll-stagger.in-view > *:nth-child(3) { opacity: 1; transform: translateY(0); transition-delay: 120ms; } +.scroll-stagger.in-view > *:nth-child(4) { opacity: 1; transform: translateY(0); transition-delay: 180ms; } +.scroll-stagger.in-view > *:nth-child(5) { opacity: 1; transform: translateY(0); transition-delay: 240ms; } +.scroll-stagger.in-view > *:nth-child(6) { opacity: 1; transform: translateY(0); transition-delay: 300ms; } +.scroll-stagger.in-view > *:nth-child(7) { opacity: 1; transform: translateY(0); transition-delay: 360ms; } +.scroll-stagger.in-view > *:nth-child(8) { opacity: 1; transform: translateY(0); transition-delay: 420ms; } + +/* ------------------------------------------------------------------ */ +/* Micro-interactions */ +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* Card depth variations */ +/* ------------------------------------------------------------------ */ + +.depth-1 { + box-shadow: 0 1px 3px rgba(20, 20, 45, 0.06); +} + +.depth-2 { + box-shadow: 0 2px 10px rgba(20, 20, 45, 0.07); +} + +.depth-3 { + box-shadow: + 0 8px 28px rgba(20, 20, 45, 0.09), + 0 2px 6px rgba(20, 20, 45, 0.05); +} + +/* ------------------------------------------------------------------ */ +/* Full-bleed breakout — a block wider than the page container, */ +/* centered on the viewport (parent must be a centered max-w column) */ +/* ------------------------------------------------------------------ */ + +@media (min-width: 80rem) { + .breakout-wide { + --breakout-w: min(100vw - 5rem, 85rem); + width: var(--breakout-w); + margin-left: calc(50% - var(--breakout-w) / 2); + } +} + +/* ------------------------------------------------------------------ */ +/* Nav sweep — a phosphor tracer riding the sticky nav's bottom */ +/* hairline; the instrument is always on */ +/* ------------------------------------------------------------------ */ + +.nav-sweep { + position: absolute; + inset-inline: 0; + bottom: -1px; + height: 1px; + overflow: hidden; + pointer-events: none; +} + +.nav-sweep::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 140px; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--accent) 55%, transparent), + transparent + ); + animation: nav-sweep 9s linear infinite; +} + +@keyframes nav-sweep { + from { + transform: translateX(-140px); + } + to { + transform: translateX(100vw); + } +} + +/* ------------------------------------------------------------------ */ +/* Reduced motion: everything settles instantly */ +/* ------------------------------------------------------------------ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + animation-timeline: auto !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + /* ambient effects are pure ambience — remove them when motion is reduced */ + .pulse-sweep { + visibility: hidden; + } + .nav-sweep { + display: none; + } + /* ambient glow drift stops; the static light wash may remain */ + .ambient-glow { + animation: none; + will-change: auto; + } + /* skeletons settle to a static placeholder */ + .skeleton { + animation: none; + background-image: none; + } + /* grain and scanline remain as they don't animate */ + body::before, + body::after { + display: none; + } + /* spotlight effects collapse to static */ + .spotlight-card::before { + display: none; + } + /* hover lifts settle */ + .hover-lift:hover { + transform: none; + } + /* glows collapse */ + .glow-accent-subtle { + box-shadow: none; + } + /* scroll animations resolve immediately */ + .scroll-animate, + .scroll-scale, + .scroll-slide-left, + .scroll-slide-right, + .scroll-slide-down, + .scroll-fade { + opacity: 1; + transform: none; + } + .scroll-stagger > * { + opacity: 1; + transform: none; + } } diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..f09343e25177382477760c231bd62522e77b43d2 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90df142ef2c4f3f39cee8b0e4c4f4771b62..5d2cbb488fa15e7426ad279869890582114aca06 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,140 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import Link from "next/link"; +import { Be_Vietnam_Pro, IBM_Plex_Mono, Instrument_Serif } from "next/font/google"; +import SiteNav from "@/components/SiteNav"; +import AmbientEffects from "@/components/ui/AmbientEffects"; +import { getDataset, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const display = Instrument_Serif({ + weight: "400", + style: ["normal", "italic"], subsets: ["latin"], + variable: "--font-display", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const body = Be_Vietnam_Pro({ + weight: ["400", "500", "600", "700"], + subsets: ["vietnamese", "latin"], + variable: "--font-body", +}); + +const mono = IBM_Plex_Mono({ + weight: ["400", "500", "600"], subsets: ["latin"], + variable: "--font-mono", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "MedASR Bench — Vietnamese Medical ASR Benchmark", + template: "%s · MedASR Bench", + }, + description: + "Public benchmark for Vietnamese medical speech recognition with English code-switching, anchored on ViMedCSS. Part of the Clinical Scribe program.", }; +const footerLink = + "transition-colors duration-200 ease-out hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const dataset = getDataset(); return ( - {children} + + + +
+ {children} +
+
+
+ {/* wordmark + program */} +
+

+ MedASR Bench +

+

+ Part of the Clinical Scribe{" "} + program — Vietnamese medical ASR, measured in the open. +

+
+ + {/* navigation echo + citation */} + + + {/* mono colophon */} +
+
+
Normalizer
+
normalizer {NORMALIZER_VERSION}
+
+
+
Harness
+
{HARNESS_VERSION}
+
+
+
License
+
benchmark data {dataset.license}
+
+
+
API snapshot policy
+
API results are date-stamped snapshots
+
+
+
+
+ ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aac84a2198a8b6d7020512f89188698e79311aae --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,59 @@ +import { + Skeleton, + SkeletonStats, + SkeletonTable, + SkeletonText, +} from "@/components/ui/LoadingSkeleton"; + +/** + * Route-level loading state for the leaderboard home. Mirrors the page + * skeleton-for-skeleton: kicker + headline + lede, dataset chip strip, + * anchor-dataset stats band, then filter controls and the table. All shimmer + * comes from the `.skeleton` class, which the global prefers-reduced-motion + * override settles to a static placeholder. + */ +export default function Loading() { + return ( +
+

+ Loading the leaderboard… +

+ + {/* ---------- hero ---------- */} +
+ + + + + {/* dataset chip strip */} +
+ + + + +
+
+ + {/* ---------- anchor-dataset stats band ---------- */} +
+ + +
+ + {/* ---------- leaderboard: title + controls + table ---------- */} +
+ +
+ + + + +
+ +
+
+ ); +} diff --git a/src/app/methodology/page.tsx b/src/app/methodology/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b77c4fe6bbaa1f4a83c7906c7e4e95668ccbe3a --- /dev/null +++ b/src/app/methodology/page.tsx @@ -0,0 +1,304 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { AlertTriangle, ArrowRight } from "lucide-react"; +import Badge from "@/components/ui/Badge"; +import CIBar from "@/components/ui/CIBar"; +import Card from "@/components/ui/Card"; +import ScrollReveal, { Stagger } from "@/components/ui/ScrollReveal"; +import SectionHeading from "@/components/ui/SectionHeading"; +import ManifestCard from "@/components/docs/ManifestCard"; +import MetricDefCard from "@/components/docs/MetricDefCard"; +import OnboardingCriteria from "@/components/docs/OnboardingCriteria"; +import ProvenanceLadder from "@/components/docs/ProvenanceLadder"; +import TracksTable from "@/components/docs/TracksTable"; + +export const metadata: Metadata = { + title: "Methodology", + description: + "Metric definitions, evaluation tracks, provenance classes, reproducibility policy, and dataset onboarding criteria for MedASR Bench.", +}; + +const NORMALIZER_STEPS = [ + "Unicode NFC normalization", + "Lowercase", + "Punctuation → space", + "Collapse repeated whitespace", + "Digits kept as-is", +]; + +export default function MethodologyPage() { + return ( +
+ {/* 1 · Header */} + + + {/* 2 · Metrics */} +
+ + + + The headline metric: word-level edit distance against the + human-verified reference, after normalizer v0.1. S, D, I are + substitutions, deletions, insertions; N is reference words. + + + The same edit distance at character level — robust to tokenization + choices and a fairer lens on Vietnamese diacritics. + + + WER restricted to code-switched English spans — the clinical + failure mode this benchmark exists to expose. Vietnamese-specialized + models win overall WER yet lose here. + + + The mirror image of CS-WER: WER over Vietnamese-only tokens with + code-switched spans excluded. Multilingual models invert the + pattern — strong here is rare when CS-WER is strong too. + + + Share of reference medical terms recovered in the hypothesis. The + exact tier requires verbatim recovery; the fuzzy tier tolerates + minor surface variation. + + + Throughput during the platform run on the manifest’s declared + hardware. Above 1.0 means faster than real time. + + + +
+ +

+ Confidence intervals & the SOTA rule +

+

+ Headline metrics carry bootstrap 95% confidence intervals from + utterance-level resampling, rendered as whisker micro-bars beside + every value. The policy is strict:{" "} + + no SOTA badge without significance + {" "} + — a model is never promoted while its CI overlaps the + incumbent’s. +

+
+

+ Illustration — overlapping CIs, no badge awarded +

+
+ incumbent + 24.80 + +
+
+ candidate + 23.90 + +
+
+
+ + +

Normalizer v0.1

+

+ Both reference and hypothesis pass through the same normalizer + before scoring. The version is recorded in every run manifest. +

+
    + {NORMALIZER_STEPS.map((step, i) => ( +
  1. + {String(i + 1).padStart(2, "0")} + {step} +
  2. + ))} +
+

+ + WER is only comparable within a normalizer version. Cross-version + numbers are never ranked against each other. +

+
+
+ + + + Term recall split by training-frequency buckets — separates + memorized vocabulary from genuine generalization. + + + Error rate over confusable drug-name pairs, where a substitution is + a patient-safety event rather than a typo. + + + p50/p90 latency and dollars per audio-hour for commercial APIs, + captured during date-stamped snapshot runs. + + +
+ + {/* 3 · Tracks */} + +
+ + +
+
+ + {/* 4 · Provenance */} + +
+ + + +
+

+ Commercial APIs are snapshots +

+ date-stamped + quarterly drift re-runs · roadmap +
+

+ API systems change behind stable names, so their rows are + date-stamped snapshots of the service on the day of the run. + Quarterly drift re-runs are on the roadmap; superseded snapshots + stay visible with their dates instead of being overwritten. +

+
+
+
+ + {/* 5 · Reproducibility */} + +
+ + + +

Anti-gaming policy

+
    +
  • + 01 + Provenance is declared per row and rendered beside the number + everywhere it appears — it never washes out in aggregation. +
  • +
  • + 02 + Contamination flags are permanent and scoped per dataset. On + ViMedCSS, Gemini-family models seeded the labels and carry the + amber flag forever, regardless of later scores. +
  • +
  • + 03 + Where a dataset’s test labels are public — as ViMedCSS’s are — + self-reported numbers are honor-system today. A private canary + subset is on the roadmap to detect test-set training. +
  • +
  • + 04 + Commercial APIs are evaluated as date-stamped snapshots; history + is preserved, never overwritten. +
  • +
+
+
+
+ + {/* 6 · Dataset onboarding */} + +
+ + + +

+ Snapshots & versioning +

+
    +
  • + 01 + + Datasets mount as frozen, versioned snapshots (e.g.{" "} + vimedcss v1). Split + membership never changes within a version. + +
  • +
  • + 02 + Any revision — new clips, relabeled spans, re-frozen splits — + creates a new dataset version and re-runs the affected + leaderboards. Old numbers stay visible, scoped to their version, + never silently edited. +
  • +
  • + 03 + Every run manifest records the dataset version alongside the + normalizer and harness versions; numbers are only ranked within + matching versions. +
  • +
+ + See the dataset registry + + +
+
+
+
+ ); +} diff --git a/src/app/models/[slug]/loading.tsx b/src/app/models/[slug]/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3dd41e4bb49ffdc58e4b661250f5ddf5eb8cc9e0 --- /dev/null +++ b/src/app/models/[slug]/loading.tsx @@ -0,0 +1,83 @@ +import { + Skeleton, + SkeletonCard, + SkeletonStats, + SkeletonText, +} from "@/components/ui/LoadingSkeleton"; + +/** + * Route-level skeleton for the model-detail page. Mirrors the real layout: + * identity header, dataset scope bar, the Results — metric grid + * (with the frontier thumb column on lg), then generalization + manifest + * cards. Server-safe — all shimmer comes from the .skeleton CSS class, so + * prefers-reduced-motion collapses it to static blocks. + */ +export default function Loading() { + return ( +
+ {/* 01 — model identity header */} +
+ +
+ + +
+
+ + + +
+ +
+ + {/* dataset scope bar */} +
+
+ + + +
+ +
+ + {/* 02 — results grid + frontier thumb column */} +
+
+
+ + +
+ + +
+
+ +
+ + +
+
+
+ + {/* 03 — generalization strip */} +
+ +
+ + +
+
+ + {/* 04/05 — per-topic + run manifest */} + + +
+ ); +} diff --git a/src/app/models/[slug]/page.tsx b/src/app/models/[slug]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ea1500f7a1715580453d5aeb91e7b027dbdad1a --- /dev/null +++ b/src/app/models/[slug]/page.tsx @@ -0,0 +1,230 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowUpRight } from "lucide-react"; +import DatasetScope from "@/components/model/DatasetScope"; +import FrontierThumb from "@/components/model/FrontierThumb"; +import GeneralizationStrip from "@/components/model/GeneralizationStrip"; +import MetricPanel from "@/components/model/MetricPanel"; +import ModelHeader from "@/components/model/ModelHeader"; +import RunManifest from "@/components/model/RunManifest"; +import StatusNotice from "@/components/model/StatusNotice"; +import TopicBreakdown from "@/components/model/TopicBreakdown"; +import PulseLine from "@/components/ui/PulseLine"; +import ScrollReveal from "@/components/ui/ScrollReveal"; +import { + ANCHOR_DATASET_ID, + getDataset, + getModel, + getModels, +} from "@/lib/data"; +import type { ModelRow } from "@/lib/types"; + +interface Props { + params: Promise<{ slug: string }>; +} + +/* Model pages are generated over the anchor dataset's leaderboard. */ +export function generateStaticParams() { + return getModels(ANCHOR_DATASET_ID).map((m) => ({ slug: m.slug })); +} + +/* The leaderboard is closed (static JSON) — unknown slugs must be a real + HTTP 404, not a streamed 200 with not-found content. */ +export const dynamicParams = false; + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const model = getModel(slug); + if (!model) return { title: "Model" }; + const dataset = getDataset(); + return { + title: model.name, + description: `${model.name} (${model.org}) — results on the ${dataset.name} benchmark, MedASR Bench.`, + }; +} + +const footerLink = + "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"; + +/** Everything a first platform run writes — listed in the awaiting panel. */ +const PENDING_CHIPS = [ + "WER", + "CS-WER", + "N-WER", + "CER", + "MTR", + "RTFx", + "Test → Hard Δ", + "per-topic WER", +] as const; + +function hasAnyMeasurement(model: ModelRow): boolean { + return (["test", "hard"] as const).some((s) => + Object.values(model.metrics?.[s] ?? {}).some((v) => typeof v === "number"), + ); +} + +function PageFooter({ slug }: { slug: string }) { + return ( +
+ + + Back to leaderboard + + + Compare this model + + +
+ ); +} + +export default async function ModelPage({ params }: Props) { + const { slug } = await params; + const model = getModel(slug); + if (!model) notFound(); + + /* Anchor dataset (ViMedCSS) — the dataset every number on this page is + scoped to. It is live, so split statistics are guaranteed present. */ + const dataset = getDataset(); + const splits = dataset.splits; + if (!splits) { + throw new Error( + `Dataset "${dataset.id}" has no split statistics — model pages require a live dataset.`, + ); + } + const models = getModels(); + + const showStatus = + model.provenance === "running" || model.provenance === "planned"; + const hasParetoPoint = + typeof model.metrics?.test?.wer === "number" && + typeof model.metrics?.test?.csWer === "number"; + const measured = hasAnyMeasurement(model); + + /* unmeasured pages collapse sections 02–04 into one composed panel and + promote the run manifest — the only real data — under the header */ + if (!measured) { + const queue = models.filter((m) => !hasAnyMeasurement(m)); + const queuePos = queue.findIndex((m) => m.slug === model.slug) + 1; + + return ( +
+
+ + + + + + +
+ {showStatus && ( + + )} +
+
+ + +
+

+ awaiting first platform run +

+

+ No trace exists for this model on {dataset.name} yet — + measurements, Test → Hard generalization and the + per-topic breakdown all land with its first run on{" "} + Test +{" "} + Hard. +

+

+ Queue position {queuePos} of {queue.length} unmeasured systems +

+
    + {PENDING_CHIPS.map((c) => ( +
  • + {c} +
  • + ))} +
+
+
+
+ + +
+ ); + } + + return ( +
+ {/* above the fold: load-orchestrated — header first, then panels */} +
+ + + + + {showStatus && ( + + )} + + {hasParetoPoint ? ( +
+ + +
+ ) : ( + + )} +
+ + {/* below the fold: scroll-revealed sections */} + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c5c4cfb7fb3c745bfd7d38b2d21a8c441d..8ee8f67c7d8f31f264a4af0bb97c6b5e229aa591 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,183 @@ -import Image from "next/image"; +import type { Metadata } from "next"; +import PulseLine from "@/components/ui/PulseLine"; +import DatasetChips from "@/components/ui/DatasetChips"; +import ScrollReveal from "@/components/ui/ScrollReveal"; +import HeroStats from "@/components/leaderboard/HeroStats"; +import LeaderboardTable from "@/components/leaderboard/LeaderboardTable"; +import { + ANCHOR_DATASET_ID, + getDataset, + getDatasets, + getLiveDatasets, + getModels, + HARNESS_VERSION, + NORMALIZER_VERSION, +} from "@/lib/data"; + +export const metadata: Metadata = { + title: "Leaderboard", +}; + +export default function LeaderboardPage() { + const datasets = getDatasets(); + const liveCount = getLiveDatasets().length; + /* The home leaderboard reads the anchor dataset. vimedcss is live, so its + measured fields are always present — hence the non-null assertions below. */ + const dataset = getDataset(ANCHOR_DATASET_ID); + const models = getModels(ANCHOR_DATASET_ID); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - + {/* ---------- hero ---------- */} +

+
+ - + + {/* instrument readout — quiet counterweight to the headline */} +
- Documentation - +
+
Datasets
+
+ {liveCount} live · {datasets.length - liveCount} onboarding +
+
+
+
Anchor set
+
{dataset.name} v1
+
+
+
Normalizer
+
{NORMALIZER_VERSION}
+
+
+
Harness
+
{HARNESS_VERSION}
+
+
+
Systems on anchor
+
{models.length}
+
+
+
+ + {/* dataset registry */} +
+ + Datasets + +
-
+ + + {/* ---------- anchor-dataset stats band ---------- */} + + + {/* ---------- ECG divider ---------- */} + + + {/* ---------- editorial insight — scoped to the anchor dataset ---------- */} +

+ On {dataset.name}, Vietnamese-specialized models win the overall word + error rate but lose the code-switched spans; multilingual models invert + the trade — adaptation closes both. +

+ + {/* ---------- leaderboard ---------- */} + + + {/* ---------- footnotes ---------- */} + +
+
    +
  1. + [1] + + All WER-family numbers were computed under text normalizer{" "} + v0.1. WER is only + comparable within a normalizer version; the version is recorded + per run. + +
  2. +
  3. + [2] + + Paper-imported rows (dashed chips) reproduce numbers from{" "} + arXiv:{dataset.arxiv} as published + — not yet re-verified on this platform. + +
  4. +
  5. + [3] + + Contamination flag: {dataset.name} labels were seeded by Gemini + 2.5 Pro before human verification (κ ={" "} + 0.65), so Gemini-family models + carry a permanent contamination flag. With public labels, + provenance declarations are honor-system for now; a private + canary subset is on the roadmap. + +
  6. +
  7. + [4] + + Domain caveat: {dataset.name}{" "} + is lecture-style YouTube audio, not noisy two-speaker clinic + conversation. Scores here do not + certify the production deployment gate (WER <{" "} + 15% on clinic speech). + +
  8. +
  9. + [5] + + Bars under WER and CS-WER place the value on a fixed{" "} + 15–65 scale; whiskers appear when + bootstrap 95% CIs are available — + platform runs record them, paper imports rarely publish them. + +
  10. +
+
+
); } diff --git a/src/app/template.tsx b/src/app/template.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32301913b862cc8f99a01419c66c994a7538130f --- /dev/null +++ b/src/app/template.tsx @@ -0,0 +1,16 @@ +"use client"; + +import PageTransition from "@/components/ui/PageTransition"; + +/** + * Root template — wraps every route's content in a fade + rise entrance. + * PageTransition keys itself by pathname, so the animation also fires on + * deep navigations that don't remount this template (e.g. between models). + */ +export default function Template({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/components/SiteNav.tsx b/src/components/SiteNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bdd6f08eb9747ea0c026d9cda5f3ec571aa3f88 --- /dev/null +++ b/src/components/SiteNav.tsx @@ -0,0 +1,137 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { motion } from "framer-motion"; +import Badge from "@/components/ui/Badge"; +import { NORMALIZER_VERSION } from "@/lib/data"; + +const LINKS = [ + { href: "/", label: "Leaderboard" }, + { href: "/compare", label: "Compare" }, + { href: "/datasets", label: "Datasets" }, + { href: "/methodology", label: "Methodology" }, +] as const; + +function isActive(pathname: string, href: string): boolean { + if (href === "/") return pathname === "/" || pathname.startsWith("/models"); + return pathname === href || pathname.startsWith(`${href}/`); +} + +/** + * Top navigation: wordmark + section links with animated active-route styling + + * normalizer chip. Features smooth animated indicator for active state. + */ +export default function SiteNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/compare/EfficiencySection.tsx b/src/components/compare/EfficiencySection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93d1b65c830a946376066dd70a4a58bfac5f3fe5 --- /dev/null +++ b/src/components/compare/EfficiencySection.tsx @@ -0,0 +1,244 @@ +"use client"; + +import type { ReactElement } from "react"; +import { + CartesianGrid, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, + ZAxis, +} from "recharts"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import { categoryLabels, fmt } from "@/lib/data"; +import type { ModelRow } from "@/lib/types"; +import { + CHART_AXIS_LABEL, + CHART_AXIS_TICK, + CHART_LABEL_FILL, + CHART_LABEL_KNOCKOUT, + categoryColor, + isApiCategory, + shortName, +} from "./compare-utils"; + +interface EffDatum { + rtfx: number; + wer: number; + paramsB: number; + model: ModelRow; +} + +function renderBubble(props: unknown): ReactElement { + const { cx, cy, size, payload } = props as { + cx?: number; + cy?: number; + size?: number; + payload?: EffDatum; + }; + if (typeof cx !== "number" || typeof cy !== "number" || !payload) { + return ; + } + const color = categoryColor[payload.model.category]; + const r = Math.max(Math.sqrt((size ?? 64) / Math.PI), 4); + const side = r * Math.SQRT2; // diamond with the same half-diagonal + return ( + + {isApiCategory(payload.model.category) ? ( + + ) : ( + + )} + + + {shortName(payload.model)} + + + ); +} + +function EffTooltip(props: unknown) { + const { active, payload } = props as { + active?: boolean; + payload?: ReadonlyArray<{ payload?: EffDatum }>; + }; + const d = payload?.[0]?.payload; + if (!active || !d) return null; + return ( +
+

{d.model.name}

+

+ {d.model.org} · {categoryLabels[d.model.category]} +

+
+
+
RTFx
+
{fmt(d.rtfx)}×
+
+
+
WER
+
{fmt(d.wer)}
+
+
+
Params
+
{fmt(d.paramsB, 3)}B
+
+
+
+ ); +} + +export interface EfficiencySectionProps { + models: ModelRow[]; + /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */ + datasetName?: string; +} + +export default function EfficiencySection({ + models, + datasetName = "ViMedCSS", +}: EfficiencySectionProps) { + const data: EffDatum[] = models.flatMap((m) => { + const s = m.metrics?.test; + if (typeof s?.rtfx !== "number" || typeof s?.wer !== "number") return []; + return [{ rtfx: s.rtfx, wer: s.wer, paramsB: m.paramsB ?? 0.1, model: m }]; + }); + const running = models.filter((m) => m.provenance === "running"); + /* a sparse plot reads as broken unless the gap is named: when most + systems are unmeasured, say so inside the plot itself */ + const unmeasured = models.length - data.length; + const sparse = data.length > 0 && unmeasured > models.length / 2; + + return ( +
+
+ {data.length > 0 ? ( +
+
+ {sparse && ( +

+ {data.length} of {models.length} systems measured — RTFx only + ships with platform-verified runs; the rest of this plane is + the evidence gap, not the field. +

+ )} + + + + + + + + + + +
+
+ RTFx = audio seconds ÷ wall seconds, measured on platform + hardware. Bubble area scales with parameter count. {datasetName}{" "} + test split · normalizer v0.1. +
+
+ ) : ( + /* slim status banner — the tall canvas is reserved for real data */ +
+ +

+ AWAITING PILOT DATA — RTFX REQUIRES A PLATFORM-VERIFIED RUN +

+ {running.length > 0 && ( +
    + {running.map((m) => ( +
  • + {m.name} + +
  • + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/compare/HeadToHead.tsx b/src/components/compare/HeadToHead.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4aa6cf5e8e34a67b5aa04470eac2801d7e2a26f4 --- /dev/null +++ b/src/components/compare/HeadToHead.tsx @@ -0,0 +1,439 @@ +"use client"; + +import clsx from "clsx"; +import { motion, MotionConfig } from "framer-motion"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; +import FlagBadges from "@/components/ui/FlagBadges"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import { + categoryLabels, + fmt, + metricLabels, + provenanceLabels, +} from "@/lib/data"; +import type { Category, MetricKey, ModelRow } from "@/lib/types"; +import { + H2H_METRICS, + METRIC_UNIT, + deltaTone, + fmtDelta, + hasEvidenceGap, + seriesColor, + type DeltaTone, +} from "./compare-utils"; + +const DEFAULT_A = "phowhisper-large"; +const DEFAULT_B = "whisper-large-v3"; + +const toneClass: Record = { + better: "text-accent", + worse: "text-crit/90", + flat: "text-muted", +}; + +/* Entrance for freshly-selected column values: a short fade with a 2px + rise. MotionConfig reducedMotion="user" strips the transform for + reduced-motion users, leaving an instant-feeling opacity settle. */ +const VALUE_FADE = { + initial: { opacity: 0, y: 2 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.3, ease: "easeOut" }, +} as const; + +/* custom select caret: 1.5px-stroke chevron in the --muted token, painted + as a background so the native OS glyph never appears */ +const SELECT_CARET: React.CSSProperties = { + 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")`, + backgroundPosition: "right 10px center", + backgroundRepeat: "no-repeat", +}; + +/** [A, B, C] slugs; C may be "" (off). Honors ?m=slug,slug(,slug). */ +function parseM(raw: string | null, models: ModelRow[]): [string, string, string] { + const valid = + raw + ?.split(",") + .map((s) => s.trim()) + .filter((s) => models.some((m) => m.slug === s)) ?? []; + const uniq = [...new Set(valid)]; + const a = uniq[0] ?? DEFAULT_A; + let b = uniq[1] ?? DEFAULT_B; + if (b === a) b = a === DEFAULT_B ? DEFAULT_A : DEFAULT_B; + const c = uniq[2] && uniq[2] !== a && uniq[2] !== b ? uniq[2] : ""; + return [a, b, c]; +} + +function syncUrl(sel: [string, string, string]) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.set("m", sel.filter(Boolean).join(",")); + window.history.replaceState(null, "", url); +} + +function ModelSelect({ + id, + label, + value, + models, + allowNone, + onChange, +}: { + id: string; + label: string; + value: string; + models: ModelRow[]; + allowNone?: boolean; + onChange: (slug: string) => void; +}) { + const groups = (Object.keys(categoryLabels) as Category[]) + .map((c) => ({ c, rows: models.filter((m) => m.category === c) })) + .filter((g) => g.rows.length > 0); + return ( +
+ + +
+ ); +} + +export interface HeadToHeadProps { + models: ModelRow[]; + /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */ + datasetName?: string; +} + +export default function HeadToHead({ + models, + datasetName = "ViMedCSS", +}: HeadToHeadProps) { + const searchParams = useSearchParams(); + const [sel, setSel] = useState<[string, string, string]>(() => + parseM(searchParams.get("m"), models), + ); + const [showUnmeasured, setShowUnmeasured] = useState(false); + + const pick = (i: 0 | 1 | 2) => (slug: string) => { + setSel((prev) => { + const next: [string, string, string] = [...prev]; + next[i] = slug; + syncUrl(next); + return next; + }); + }; + + const cols = sel + .filter(Boolean) + .map((s) => models.find((m) => m.slug === s)) + .filter((m): m is ModelRow => Boolean(m)); + const baseline = cols[0]; + const gapModels = cols.filter((m) => hasEvidenceGap(m, "test")); + /* metrics at least one selected model has measured drive the table rows + and the profile bars; the rest collapse into one quiet expandable row */ + const measuredMetrics = H2H_METRICS.filter((k) => + cols.some((m) => typeof m.metrics?.test?.[k] === "number"), + ); + const unmeasuredMetrics = H2H_METRICS.filter( + (k) => !cols.some((m) => typeof m.metrics?.test?.[k] === "number"), + ); + const barMetrics = measuredMetrics; + + return ( + +
+
+ {/* selectors */} +
+ + + +
+ + {/* evidence-gap notices */} + {gapModels.length > 0 && ( +
+ {gapModels.map((m) => ( + + + EVIDENCE GAP — {m.name} has no measurements on Test yet. + + + + ))} +
+ )} + + {/* delta table */} +
+ + + + + + {cols.map((m, i) => ( + + ))} + + + + {(showUnmeasured + ? [...measuredMetrics, ...unmeasuredMetrics] + : measuredMetrics + ).map((k: MetricKey) => { + const base = baseline?.metrics?.test?.[k]; + return ( + + + {cols.map((m, i) => { + const v = m.metrics?.test?.[k]; + const showDelta = + i > 0 && + typeof v === "number" && + typeof base === "number"; + const d = showDelta ? v - base : 0; + return ( + /* keyed by slug: swapping a model remounts the + cell, so the fresh value fades in */ + + ); + })} + + ); + })} + {unmeasuredMetrics.length > 0 && ( + + + + )} + +
+ Metric comparison on the {datasetName} Test split. Deltas are + challenger minus baseline; green improves on the baseline, red + regresses. +
+ Metric · Test + + + + {m.name} + + {i === 0 && ( + + BASELINE + + )} + +
+ {metricLabels[k]}{" "} + {METRIC_UNIT[k]} + + + + {fmt(v)} + + {showDelta && ( + + {fmtDelta(d)} + + )} + +
+ +
+
+ + {/* grouped horizontal bars */} + {barMetrics.length > 0 && ( +
+

+ Profile — grouped bars, Test split +

+
+ {barMetrics.map((k) => { + const values = cols.map((m) => m.metrics?.test?.[k]); + const max = Math.max( + ...values.map((v) => (typeof v === "number" ? v : 0)), + ); + /* %-unit metrics share one fixed 0–100 domain so equal bar + lengths always encode equal values; RTFx (open-ended) + keeps local-max scaling */ + const isPct = METRIC_UNIT[k] === "%"; + const widthPct = (v: number) => + isPct + ? Math.min(v, 100) + : max > 0 + ? Math.max((v / (max * 1.08)) * 100, 1.5) + : 0; + return ( +
+

+ {metricLabels[k]}{" "} + + {METRIC_UNIT[k]} + {isPct && " · 0–100"} + +

+
+ {/* rows are keyed by SLOT, not slug — the bar element + survives a model swap, so its width tweens to the + new value (CSS transition; reduced-motion safe) */} + {cols.map((m, i) => { + const v = values[i]; + return ( +
+
+ {/* scale ticks at 25 / 50 / 75 */} + {isPct && + [25, 50, 75].map((t) => ( + + ))} + {typeof v === "number" && + (i === 2 ? ( + /* series 3: hairline-outlined, transparent + fill — distinct without more accent */ + + ) : ( + + ))} +
+ + {fmt(v)} + +
+ ); + })} +
+
+ ); + })} +
+
+ )} + +

+ ALL VALUES: {datasetName.toUpperCase()} TEST SPLIT · NORMALIZER v0.1 + — WER IS ONLY COMPARABLE WITHIN A NORMALIZER VERSION. +

+
+
+
+ ); +} diff --git a/src/components/compare/ParetoChart.tsx b/src/components/compare/ParetoChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..26f714dfe01195c6585de87c26ac25e580325eac --- /dev/null +++ b/src/components/compare/ParetoChart.tsx @@ -0,0 +1,350 @@ +"use client"; + +import type { ReactElement } from "react"; +import { + CartesianGrid, + ReferenceArea, + ReferenceDot, + ReferenceLine, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import { categoryLabels, fmt, fmtCI } from "@/lib/data"; +import { + CHART_AXIS_LABEL, + CHART_AXIS_TICK, + CHART_LABEL_FILL, + CHART_LABEL_KNOCKOUT, + categoryColor, + dominatedBands, + domainTicks, + frontierSegments, + isApiCategory, + niceDomain, + paretoFrontier, + shortName, + type ParetoDatum, +} from "./compare-utils"; + +/* Datum enriched with chart-level placement hints. `annotated` marks the + frontier point the serif "adapted frontier" annotation anchors to — its + FRONTIER tag is suppressed so captions never pile onto one point. */ +type PlottedDatum = ParetoDatum & { + onFrontier: boolean; + flip: boolean; + annotated: boolean; +}; + +/* ------------------------------------------------------------------ */ +/* Point glyph: category-colored dot (diamond for API systems) · */ +/* dashed ring = paper-imported · amber ring = contamination · */ +/* concentric accent ring + FRONTIER tag for frontier members · */ +/* tiny mono label */ +/* ------------------------------------------------------------------ */ + +function renderPoint(props: unknown): ReactElement { + const { cx, cy, payload } = props as { + cx?: number; + cy?: number; + payload?: PlottedDatum; + }; + if (typeof cx !== "number" || typeof cy !== "number" || !payload) { + return ; + } + const m = payload.model; + const color = categoryColor[m.category]; + const dashed = m.provenance === "paper-imported"; + const contaminated = Boolean(m.flags?.contamination); + const frontier = payload.onFrontier; + const flip = payload.flip; + const annotated = payload.annotated; + return ( + + {contaminated && ( + + )} + {frontier && ( + + )} + {isApiCategory(m.category) ? ( + + ) : ( + + )} + + {/* frontier members: name lifted clear above the dot + mono tag below */} + + {shortName(m)} + + {frontier && !annotated && ( + + FRONTIER + + )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Instrument tooltip — glass over the chart, never opaque black */ +/* ------------------------------------------------------------------ */ + +function ParetoTooltip(props: unknown) { + const { active, payload } = props as { + active?: boolean; + payload?: ReadonlyArray<{ payload?: ParetoDatum }>; + }; + const d = payload?.[0]?.payload; + if (!active || !d) return null; + const m = d.model; + return ( +
+

{m.name}

+

+ {m.org} · {categoryLabels[m.category]} +

+
+
+
WER
+
+ {fmt(d.wer)} + {d.werCi && ( + + {fmtCI(d.werCi)} + + )} +
+
+
+
CS-WER
+
+ {fmt(d.csWer)} + {d.csWerCi && ( + + {fmtCI(d.csWerCi)} + + )} +
+
+
+
+ +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Chart */ +/* ------------------------------------------------------------------ */ + +export interface ParetoChartProps { + data: ParetoDatum[]; + splitLabel: string; +} + +export default function ParetoChart({ data, splitLabel }: ParetoChartProps) { + /* axis domains track the data extent (padded, snapped to 5s) so the + points command the canvas instead of huddling in a corner */ + const xDomain = niceDomain(data.map((d) => d.wer)); + const yDomain = niceDomain(data.map((d) => d.csWer)); + const xTicks = domainTicks(xDomain); + const yTicks = domainTicks(yDomain); + + const frontier = paretoFrontier(data); + const frontierSet = new Set(frontier.map((d) => d.model.slug)); + const segments = frontierSegments(frontier, xDomain[1], yDomain[1]); + const bands = dominatedBands(frontier, xDomain[1], yDomain[1]); + const last = frontier[frontier.length - 1]; + + const plotted: PlottedDatum[] = data.map((d) => ({ + ...d, + onFrontier: frontierSet.has(d.model.slug), + flip: d.wer > (xDomain[0] + xDomain[1]) / 2, + annotated: Boolean(last) && d.model.slug === last.model.slug, + })); + + return ( +
+ + + + + + {/* dominated region: the staircase boundary reads as a region edge */} + {bands.map((b, i) => ( + + ))} + {segments.map(([a, b], i) => ( + + ))} + {/* data-anchored frontier annotation — floats above the frontier's + horizontal run-out, clear of the frontier point's own label */} + {last && ( + + )} + + + + + + {/* Quadrant captions — editorial, pinned at fixed insets from the + plot corners (plot edges: left 52px, top 24px, right 36px, + bottom ≈46px) so they frame the data */} +

+ Vietnamese-specialized: strong overall, weak CS +

+

+ multilingual: strong CS, weak overall +

+
+ ); +} diff --git a/src/components/compare/ParetoSection.tsx b/src/components/compare/ParetoSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c2ea3e800ad955864747150ae2acf9b868dc319 --- /dev/null +++ b/src/components/compare/ParetoSection.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useMemo, useState } from "react"; +import SplitToggle from "@/components/ui/SplitToggle"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import { categoryLabels, splitLabels } from "@/lib/data"; +import type { Category, ModelRow, Split } from "@/lib/types"; +import { categoryColor, isApiCategory, paretoData } from "./compare-utils"; +import ParetoChart from "./ParetoChart"; + +export interface ParetoSectionProps { + models: ModelRow[]; + /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */ + datasetName?: string; +} + +export default function ParetoSection({ + models, + datasetName = "ViMedCSS", +}: ParetoSectionProps) { + const [split, setSplit] = useState("test"); + const data = useMemo(() => paretoData(models, split), [models, split]); + const running = models.filter((m) => m.provenance === "running"); + + const categories = useMemo(() => { + const present = new Set(data.map((d) => d.model.category)); + return (Object.keys(categoryLabels) as Category[]).filter((c) => + present.has(c), + ); + }, [data]); + + return ( +
+
+ {/* control row: legend chips left, split toggle right */} +
+
    + {categories.map((c) => ( +
  • + + {categoryLabels[c]} +
  • + ))} +
  • + + paper-imported +
  • +
  • + + contamination +
  • +
  • + + Pareto frontier +
  • +
+ +
+ + {/* keyed on split so toggling cross-fades the whole canvas in; + soft-appear collapses under the global reduced-motion override */} +
+ {data.length > 0 ? ( + + ) : ( +
+ +

+ NO PAIRED WER / CS-WER RESULTS ON THE {datasetName.toUpperCase()}{" "} + {splitLabels[split].toUpperCase()} SPLIT YET +

+

+ Published baselines did not report this split. The first + platform-verified points will land here when the pilot runs + complete. +

+ {running.length > 0 && ( +
    + {running.map((m) => ( +
  • + + {m.name} + + +
  • + ))} +
+ )} +
+ )} +
+ + The trade-off, in one plot: + {" "} + on {datasetName}, Vietnamese-specialized models hold the left wall + but drift high on code-switched spans, multilingual models sink + lower on the right — and only adaptation reaches the lower-left, + where PhoWhisper-small + Attention-Guide sets the frontier at{" "} + 23.67 WER /{" "} + 19.50 CS-WER. +
+
+
+
+ ); +} diff --git a/src/components/compare/compare-utils.ts b/src/components/compare/compare-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..727034c112d5633273cac8e5c9f753423e2fb7c9 --- /dev/null +++ b/src/components/compare/compare-utils.ts @@ -0,0 +1,268 @@ +import type { Category, MetricKey, ModelRow, Split } from "@/lib/types"; +import { HIGHER_IS_BETTER } from "@/lib/data"; + +/* ------------------------------------------------------------------ */ +/* Category color mapping — derived ONLY from globals.css tokens. */ +/* vn-open = phosphor accent · multilingual = cool ice (accent→text) */ +/* medical = sage · api-* = cool/neutral mixes drawn as DIAMONDS, so */ +/* the warm --warn channel belongs exclusively to contamination. */ +/* ------------------------------------------------------------------ */ + +export const categoryColor: Record = { + "vn-open": "var(--accent)", + "multilingual-open": "color-mix(in srgb, var(--accent) 65%, var(--text) 35%)", + medical: "var(--text)", + "api-intl": "color-mix(in srgb, var(--text) 55%, var(--muted) 45%)", + "api-vn": "var(--muted)", +}; + +/** API points render as 45°-rotated squares instead of dots. */ +export function isApiCategory(c: Category): boolean { + return c === "api-intl" || c === "api-vn"; +} + +/* ------------------------------------------------------------------ */ +/* Shared Recharts text styles — single source for both compare */ +/* charts. 11px floor and token-driven fills keep SVG text legible on */ +/* the lighter glass panels. */ +/* ------------------------------------------------------------------ */ + +export const CHART_AXIS_TICK = { + fill: "var(--muted)", + fontSize: 11, + fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace", +} as const; + +export const CHART_AXIS_LABEL = { + fill: "var(--muted)", + fontSize: 11, + letterSpacing: "0.14em", + fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace", +} as const; + +/** Point labels knock out lines beneath them with a panel-toned halo — + charts now sit on glass surfaces, so the halo matches --surface, + not the page background. */ +export const CHART_LABEL_KNOCKOUT = { + paintOrder: "stroke", + stroke: "var(--surface)", + strokeWidth: 4, +} as const; + +/** Near-text fill for chart point labels (brighter than --muted). */ +export const CHART_LABEL_FILL = + "color-mix(in srgb, var(--text) 86%, var(--muted) 14%)"; + +/** Selection-order palette for head-to-head columns and profile bars. + Series 2 reads as bone-white with a teal cast so it never doubles the + accent; series 3 bars render hairline-outlined with transparent fill. */ +export const seriesColor: readonly string[] = [ + "var(--accent)", + "color-mix(in srgb, var(--accent) 15%, var(--text) 85%)", + "var(--muted)", +]; + +/* ------------------------------------------------------------------ */ +/* Compact display names for chart point labels */ +/* ------------------------------------------------------------------ */ + +const SHORT_NAMES: Record = { + "phowhisper-small-ag": "PhoWhisper-S ·AG", + "phowhisper-small-lora": "PhoWhisper-S ·LoRA", + vietasr: "VietASR 68M", + "phowhisper-large": "PhoWhisper-L", + "whisper-large-v3": "Whisper-L v3", + "phowhisper-small": "PhoWhisper-S", + "wav2vec2-base-vi": "w2v2-base-vi", + "whisper-small": "Whisper-S", + "mms-1b": "MMS-1B", + "zipformer-30m": "Zipformer-30M", + "qwen3-asr-0.6b": "Qwen3-ASR-0.6B", + "gemini-2.5-pro": "Gemini 2.5 Pro", + "gpt-4o-transcribe": "gpt-4o-transcribe", + "elevenlabs-scribe-v2": "Scribe v2", + "fpt-ai-stt": "FPT.AI STT", +}; + +export function shortName(m: ModelRow): string { + return SHORT_NAMES[m.slug] ?? m.name; +} + +/* ------------------------------------------------------------------ */ +/* Pareto math (minimize both axes; lower-left is better) */ +/* ------------------------------------------------------------------ */ + +export interface ParetoDatum { + wer: number; + csWer: number; + werCi?: [number, number]; + csWerCi?: [number, number]; + model: ModelRow; +} + +/** One datum per model holding BOTH wer and csWer on the split. */ +export function paretoData(models: ModelRow[], split: Split): ParetoDatum[] { + return models.flatMap((m) => { + const s = m.metrics?.[split]; + if (typeof s?.wer !== "number" || typeof s?.csWer !== "number") return []; + return [ + { + wer: s.wer, + csWer: s.csWer, + werCi: s.werCi, + csWerCi: s.csWerCi, + model: m, + }, + ]; + }); +} + +/** Non-dominated subset, sorted by WER ascending (csWer strictly falls). */ +export function paretoFrontier(points: ParetoDatum[]): ParetoDatum[] { + const sorted = [...points].sort((a, b) => a.wer - b.wer || a.csWer - b.csWer); + const out: ParetoDatum[] = []; + let best = Infinity; + for (const p of sorted) { + if (p.csWer < best) { + out.push(p); + best = p.csWer; + } + } + return out; +} + +export type Segment = readonly [ + { x: number; y: number }, + { x: number; y: number }, +]; + +/** + * Staircase boundary of the region dominated by the frontier, + * extended to the chart domain edges. Steps go right, then down. + */ +export function frontierSegments( + frontier: ParetoDatum[], + xMax: number, + yMax: number, +): Segment[] { + if (frontier.length === 0) return []; + const segs: Segment[] = []; + const first = frontier[0]; + const last = frontier[frontier.length - 1]; + // drop in from the top edge to the best-WER point + segs.push([ + { x: first.wer, y: yMax }, + { x: first.wer, y: first.csWer }, + ]); + for (let i = 0; i < frontier.length - 1; i += 1) { + const a = frontier[i]; + const b = frontier[i + 1]; + segs.push([ + { x: a.wer, y: a.csWer }, + { x: b.wer, y: a.csWer }, + ]); + segs.push([ + { x: b.wer, y: a.csWer }, + { x: b.wer, y: b.csWer }, + ]); + } + // run out to the right edge at the best CS-WER level + segs.push([ + { x: last.wer, y: last.csWer }, + { x: xMax, y: last.csWer }, + ]); + return segs; +} + +/** + * Non-overlapping vertical bands covering the dominated region (up-right + * of the staircase): band i spans x from frontier[i] to frontier[i+1] + * (last band runs to xMax), y from frontier[i].csWer to yMax. + */ +export function dominatedBands( + frontier: ParetoDatum[], + xMax: number, + yMax: number, +): Array<{ x1: number; x2: number; y1: number; y2: number }> { + return frontier.map((p, i) => ({ + x1: p.wer, + x2: frontier[i + 1]?.wer ?? xMax, + y1: p.csWer, + y2: yMax, + })); +} + +/* ------------------------------------------------------------------ */ +/* Axis domain helpers (data-driven, padded, rounded to 2s or 5s) */ +/* ------------------------------------------------------------------ */ + +/** + * Data extent padded ~10–15% and snapped outward — to multiples of 2 when + * the span is tight (< 15 units) so the points command the canvas, else 5. + */ +export function niceDomain(values: number[]): [number, number] { + if (values.length === 0) return [0, 100]; + const min = Math.min(...values); + const max = Math.max(...values); + const pad = Math.max((max - min) * 0.1, 1.5); + const snap = max - min < 15 ? 2 : 5; + return [ + Math.floor((min - pad) / snap) * snap, + Math.ceil((max + pad) / snap) * snap, + ]; +} + +/** Tick positions for a niceDomain: every 2 / 5 / 10 by span width. */ +export function domainTicks([lo, hi]: [number, number]): number[] { + const span = hi - lo; + const step = span <= 16 ? 2 : span <= 30 ? 5 : 10; + const out: number[] = []; + for (let t = lo; t <= hi; t += step) out.push(t); + return out; +} + +/* ------------------------------------------------------------------ */ +/* Head-to-head helpers */ +/* ------------------------------------------------------------------ */ + +export const H2H_METRICS: readonly MetricKey[] = [ + "wer", + "csWer", + "nWer", + "cer", + "mtrExact", + "mtrFuzzy", + "rtfx", +]; + +export const METRIC_UNIT: Record = { + wer: "%", + cer: "%", + csWer: "%", + nWer: "%", + mtrExact: "%", + mtrFuzzy: "%", + rtfx: "×", +}; + +export type DeltaTone = "better" | "worse" | "flat"; + +/** Sign-aware quality of a challenger-minus-baseline delta. */ +export function deltaTone(delta: number, metric: MetricKey): DeltaTone { + if (delta === 0) return "flat"; + const improved = HIGHER_IS_BETTER.has(metric) ? delta > 0 : delta < 0; + return improved ? "better" : "worse"; +} + +/** "+3.23" / "−1.40" / "±0.00" (true minus sign for mono alignment). */ +export function fmtDelta(delta: number, digits = 2): string { + const sign = delta > 0 ? "+" : delta < 0 ? "−" : "±"; + return `${sign}${Math.abs(delta).toFixed(digits)}`; +} + +/** True when the model has no numeric metric at all on the split. */ +export function hasEvidenceGap(m: ModelRow, split: Split): boolean { + const s = m.metrics?.[split]; + if (!s) return true; + return !H2H_METRICS.some((k) => typeof s[k] === "number"); +} diff --git a/src/components/docs/CautionCallout.tsx b/src/components/docs/CautionCallout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d551443eaaef8c6197a673efd46ccf6b6a4c8619 --- /dev/null +++ b/src/components/docs/CautionCallout.tsx @@ -0,0 +1,45 @@ +import clsx from "clsx"; +import { AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; + +export interface CautionCalloutProps { + /** mono kicker, e.g. "Caution · Label provenance" */ + eyebrow?: string; + title: string; + children: ReactNode; + className?: string; +} + +/** + * Prominent amber caution block — reserved for contamination and + * domain-validity warnings (--warn semantics only). + */ +export default function CautionCallout({ + eyebrow = "Caution", + title, + children, + className, +}: CautionCalloutProps) { + return ( + + ); +} diff --git a/src/components/docs/HourBar.tsx b/src/components/docs/HourBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f9dc2413b9c240b33df5373309edae86c3f93b8 --- /dev/null +++ b/src/components/docs/HourBar.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; + +export interface HourBarProps { + /** 0..1 portion of the track to fill */ + share: number; + /** accent = primary leaderboard data · accent-soft = secondary live data · muted = everything else */ + tone?: "muted" | "accent" | "accent-soft"; + className?: string; +} + +const tones: Record, string> = { + /* token-derived teal-tinted fill so even "everything else" bars carry + a quiet phosphor cast instead of grey-on-grey */ + muted: "bg-[color-mix(in_srgb,var(--accent)_18%,var(--muted)_50%)]", + accent: "bg-accent/75", + "accent-soft": "bg-accent/40", +}; + +/** + * Proportional hour bar. Purely decorative — always pair with the printed + * number; the bar itself is aria-hidden. + */ +export default function HourBar({ + share, + tone = "muted", + className, +}: HourBarProps) { + const clamped = Math.max(0, Math.min(1, share)); + return ( +
+
+
+ ); +} diff --git a/src/components/docs/ManifestCard.tsx b/src/components/docs/ManifestCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5121d8db728b4fd2b18e2fc9820669bf999a368 --- /dev/null +++ b/src/components/docs/ManifestCard.tsx @@ -0,0 +1,79 @@ +import { ANCHOR_DATASET_ID, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data"; + +export interface ManifestCardProps { + /** Registry id rendered in the exemplar's dataset field. */ + datasetId?: string; + className?: string; +} + +const manifestFor = (datasetId: string) => `{ + "run_id": "2026-06-09T21:14:02Z-a41f", + "model": { "id": "vinai/PhoWhisper-small", "revision": "84f3c1d" }, + "harness": "${HARNESS_VERSION}", + "normalizer": "${NORMALIZER_VERSION}", + "dataset": { "id": "${datasetId}", "split": "test" }, + "hardware": "1x RTX 4090 24GB / CUDA 12.4", + "seeds": { "global": 1337 }, + "decode": { "beam_size": 5, "temperature": 0.0, "bias_list": null } +}`; + +const FIELDS: ReadonlyArray<{ key: string; gloss: string }> = [ + { + key: "model.revision", + gloss: "Pins exact weights — a commit hash, never a moving tag.", + }, + { + key: "harness", + gloss: "Version of the scoring code that produced every number in the row.", + }, + { + key: "normalizer", + gloss: "Text-normalization version. WER is only comparable within it.", + }, + { + key: "dataset", + gloss: + "Registry id and split the run scored — every manifest is scoped to exactly one dataset.", + }, + { + key: "hardware", + gloss: "The machine behind RTFx — published so speed claims have context.", + }, + { + key: "seeds", + gloss: "Decode determinism: same manifest in, same transcript out.", + }, + { + key: "decode", + gloss: "Beam, temperature, bias list — Track B records the bias-list hash here.", + }, +]; + +/** Anatomy of a run manifest: schematic mono block plus field glosses. */ +export default function ManifestCard({ + datasetId = ANCHOR_DATASET_ID, + className, +}: ManifestCardProps) { + return ( +
+

+ run_manifest.json — published with every platform-verified run +

+
+        {manifestFor(datasetId)}
+      
+
+ {FIELDS.map((f) => ( +
+
{f.key}
+
+ {f.gloss} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/docs/MetricDefCard.tsx b/src/components/docs/MetricDefCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1133fbaee065069bee1968e075e3b0a6ed49d135 --- /dev/null +++ b/src/components/docs/MetricDefCard.tsx @@ -0,0 +1,77 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import Badge from "@/components/ui/Badge"; + +export interface MetricDefCardProps { + /** short symbol, e.g. "CS-WER" */ + abbr: string; + /** spelled-out name, e.g. "Code-switch word error rate" */ + name: string; + /** mono formula line; omit on roadmap ghosts without a fixed definition */ + formula?: string; + /** "lower is better" / "higher is better" */ + direction?: string; + /** ghost treatment for roadmap metrics */ + roadmap?: boolean; + children: ReactNode; + className?: string; +} + +/** Typeset metric definition card: serif symbol, mono formula, prose gloss. */ +export default function MetricDefCard({ + abbr, + name, + formula, + direction, + roadmap = false, + children, + className, +}: MetricDefCardProps) { + return ( +
+
+

+ {abbr} +

+ {roadmap ? ( + Roadmap + ) : ( + direction && ( + + {direction} + + ) + )} +
+

+ {name} +

+ {formula && ( + + {formula} + + )} +

{children}

+
+ ); +} diff --git a/src/components/docs/OnboardingCriteria.tsx b/src/components/docs/OnboardingCriteria.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20b268b3b7b817914e15ede6af1297665fa3c057 --- /dev/null +++ b/src/components/docs/OnboardingCriteria.tsx @@ -0,0 +1,76 @@ +import clsx from "clsx"; + +export interface OnboardingCriterion { + title: string; + detail: string; +} + +/** + * The six onboarding criteria — single source of truth, consumed by the + * methodology #onboarding section, the datasets index CTA card, and the + * coming-soon dataset teasers. + */ +export const ONBOARDING_CRITERIA: ReadonlyArray = [ + { + title: "Open or contracted license", + detail: + "Redistribution rights documented up front — an open license, or a signed contract for held-out corpora like the private clinic set.", + }, + { + title: "Transparent provenance", + detail: + "Where every clip came from, how transcripts were produced, and who verified them — published as part of the dataset profile.", + }, + { + title: "Fixed frozen splits", + detail: + "Train, Valid, Test (and Hard where defined) frozen before the first run. Any revision creates a new dataset version — never a silent edit.", + }, + { + title: "Contamination policy", + detail: + "Systems that touched the labels — for example LLM-seeded transcripts — are declared and flagged permanently on that dataset's leaderboards.", + }, + { + title: "Normalizer compatibility", + detail: + "References must score cleanly under the platform normalizer; orthographic quirks are documented and resolved before the first run.", + }, + { + title: "Six-metric coverage", + detail: + "WER, CER, CS-WER, N-WER, MTR, and RTFx must be computable from the references — or the gaps declared on the dataset profile.", + }, +]; + +export interface OnboardingCriteriaProps { + items?: ReadonlyArray; + className?: string; +} + +/** Numbered grid of dataset-onboarding criteria. */ +export default function OnboardingCriteria({ + items = ONBOARDING_CRITERIA, + className, +}: OnboardingCriteriaProps) { + return ( +
    + {items.map((item, i) => ( +
  1. +
    + + {String(i + 1).padStart(2, "0")} + +

    {item.title}

    +
    +

    + {item.detail} +

    +
  2. + ))} +
+ ); +} diff --git a/src/components/docs/PipelineSteps.tsx b/src/components/docs/PipelineSteps.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b7679cce95d3c68db4454ceed662622e9c5e7fd --- /dev/null +++ b/src/components/docs/PipelineSteps.tsx @@ -0,0 +1,48 @@ +export interface PipelineStep { + title: string; + detail: string; + /** mono stat shown beside the title, e.g. "64,232 entries" */ + stat?: string; +} + +export interface PipelineStepsProps { + steps: PipelineStep[]; +} + +/** Numbered construction-pipeline steps with a hairline connector spine. */ +export default function PipelineSteps({ steps }: PipelineStepsProps) { + return ( +
    + {steps.map((step, i) => ( +
  1. + {i < steps.length - 1 && ( + + )} + + {String(i + 1).padStart(2, "0")} + +
    +
    +

    {step.title}

    + {step.stat && ( + {step.stat} + )} +
    +

    + {step.detail} +

    +
    +
  2. + ))} +
+ ); +} diff --git a/src/components/docs/ProvenanceLadder.tsx b/src/components/docs/ProvenanceLadder.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71b034103a089e0bd4d047c91d3f274fa40827dc --- /dev/null +++ b/src/components/docs/ProvenanceLadder.tsx @@ -0,0 +1,82 @@ +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import type { Provenance } from "@/lib/types"; + +const LADDER: ReadonlyArray<{ p: Provenance; desc: string }> = [ + { + p: "platform-verified", + 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.", + }, + { + p: "self-reported", + 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.", + }, + { + p: "api-snapshot", + 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.", + }, + { + p: "paper-imported", + desc: "Transcribed from a published paper. Normalizer and decode settings may differ from ours, so these rows wear a dashed outline everywhere they appear.", + }, +]; + +const STATES: ReadonlyArray<{ p: Provenance; desc: string }> = [ + { + p: "running", + desc: "A platform run is executing right now. Metrics appear the moment the run completes and is verified.", + }, + { + p: "planned", + desc: "Queued for a platform run. No numbers yet — the row exists so the roadmap is legible.", + }, +]; + +/** Trust ladder: three provenance classes ranked, plus the two row states. */ +export default function ProvenanceLadder() { + return ( +
+
    + {LADDER.map((item, i) => ( +
  1. + + {String(i + 1).padStart(2, "0")} + + +

    {item.desc}

    +
  2. + ))} +
+ +
+

+ Row states — not trust classes +

+
    + {STATES.map((item, i) => ( +
  • + + — + + +

    + {item.desc} +

    +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/docs/RoadmapStrip.tsx b/src/components/docs/RoadmapStrip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b2760526b84c4b319acfe58858eec0061233938a --- /dev/null +++ b/src/components/docs/RoadmapStrip.tsx @@ -0,0 +1,81 @@ +import clsx from "clsx"; +import Badge from "@/components/ui/Badge"; + +export interface RoadmapItem { + name: string; + status: "live" | "next" | "north-star"; + /** leading data figure, typeset in mono (e.g. "34.57 h") */ + stat?: string; + detail: string; +} + +export interface RoadmapStripProps { + items: RoadmapItem[]; +} + +const statusMeta: Record< + RoadmapItem["status"], + { label: string; variant: "accent" | "outline" | "ghost"; card: string } +> = { + live: { + label: "Live", + variant: "accent", + card: "hairline depth-1 bg-surface", + }, + next: { + label: "Next", + variant: "outline", + card: "hairline depth-1 bg-surface", + }, + "north-star": { + label: "North star", + variant: "ghost", + card: "border border-dashed border-line bg-transparent", + }, +}; + +/** Multi-dataset roadmap: live anchor → next corpus → north-star evaluation. */ +export default function RoadmapStrip({ items }: RoadmapStripProps) { + return ( +
    + {items.map((item, i) => { + const meta = statusMeta[item.status]; + return ( +
  1. +
    + + {String(i + 1).padStart(2, "0")} + + + {item.status === "live" && ( + + )} + {meta.label} + +
    +

    + {item.name} +

    +

    + {item.stat && ( + {item.stat} + )} + {item.detail} +

    +
  2. + ); + })} +
+ ); +} diff --git a/src/components/docs/SplitMiniBar.tsx b/src/components/docs/SplitMiniBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6fde8db69582af959c5b1d4b828376f17203b928 --- /dev/null +++ b/src/components/docs/SplitMiniBar.tsx @@ -0,0 +1,74 @@ +import clsx from "clsx"; +import { fmt } from "@/lib/data"; +import type { DatasetInfo } from "@/lib/types"; + +type Splits = NonNullable; + +export interface SplitMiniBarProps { + splits: Splits; + className?: string; +} + +/* Segment palette mirrors HourBar tones: Test carries the phosphor note, + Hard a softer echo, Train/Valid stay quiet teal-greys. */ +const SEGMENTS: ReadonlyArray<{ + key: keyof Splits; + label: string; + fill: string; +}> = [ + { + key: "train", + label: "Train", + fill: "bg-[color-mix(in_srgb,var(--accent)_16%,var(--muted)_46%)]", + }, + { + key: "valid", + label: "Valid", + fill: "bg-[color-mix(in_srgb,var(--accent)_10%,var(--muted)_30%)]", + }, + { key: "test", label: "Test", fill: "bg-accent/75" }, + { key: "hard", label: "Hard", fill: "bg-accent/40" }, +]; + +/** + * Compact split-distribution readout for dataset cards: one segmented + * hour bar plus a mono legend. The bar is decorative; the legend carries + * the numbers. + */ +export default function SplitMiniBar({ splits, className }: SplitMiniBarProps) { + const total = SEGMENTS.reduce((sum, s) => sum + splits[s.key].hours, 0); + + return ( +
+
+ {SEGMENTS.map((s) => ( +
+ ))} +
+ {/* 2×2 legend — four columns crowd the card's narrow readout column + and wrap the unit onto its own line */} +
+ {SEGMENTS.map((s) => ( +
+ +
{s.label}
+
+ {fmt(splits[s.key].hours)} + h +
+
+ ))} +
+
+ ); +} diff --git a/src/components/docs/SplitsTable.tsx b/src/components/docs/SplitsTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..61eb3d9d773760c3e3a455b516f457a0ca85d048 --- /dev/null +++ b/src/components/docs/SplitsTable.tsx @@ -0,0 +1,138 @@ +import Badge from "@/components/ui/Badge"; +import { fmt } from "@/lib/data"; +import type { DatasetInfo } from "@/lib/types"; +import HourBar from "./HourBar"; + +export interface SplitsTableProps { + /** Frozen split inventory of a live dataset. */ + splits: NonNullable; + /** Dataset name for the accessible caption, e.g. "ViMedCSS". */ + datasetName?: string; +} + +const ROWS: ReadonlyArray<{ + key: keyof NonNullable; + name: string; + tone: "muted" | "accent" | "accent-soft"; + badge?: { label: string; variant: "accent" | "outline" }; + role: string; +}> = [ + { + key: "train", + name: "Train", + tone: "muted", + role: "Adaptation corpus — Track C systems may train on this split and nothing else.", + }, + { + key: "valid", + name: "Valid", + tone: "muted", + role: "Hyperparameter tuning and checkpoint selection. Never reported as a result.", + }, + { + key: "test", + name: "Test", + tone: "accent", + badge: { label: "Primary", variant: "accent" }, + role: "The primary leaderboard — every headline number on the home page is scored here.", + }, + { + key: "hard", + name: "Hard", + tone: "accent-soft", + badge: { label: "Generalization", variant: "outline" }, + role: "Rare and unseen terms, disjoint from Train — the generalization leaderboard.", + }, +]; + +/** Dataset split table with proportional hour-bars and role captions. */ +export default function SplitsTable({ splits, datasetName }: SplitsTableProps) { + const totalHours = ROWS.reduce((sum, r) => sum + splits[r.key].hours, 0); + + return ( +
+ + + + + + + + + + + + + {ROWS.map((row, i) => { + const s = splits[row.key]; + const share = s.hours / totalHours; + return ( + + + + + + + + ); + })} + +
+ {datasetName ?? "Dataset"} splits: hours, utterances, share of total + audio, and the role of each split +
+ Split + + Hours + + Utterances + + Share of audio + + Role +
+ + {row.name} + {row.badge && ( + {row.badge.label} + )} + + + {fmt(s.hours)} + h + + {s.utts.toLocaleString("en-US")} + +
+ + + {(share * 100).toFixed(1)}% + +
+
+ {row.role} +
+
+ ); +} diff --git a/src/components/docs/StatTile.tsx b/src/components/docs/StatTile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..618f52b60b1316aa1ef091ba0c99d09c8a156d25 --- /dev/null +++ b/src/components/docs/StatTile.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; + +export interface StatTileProps { + label: string; + /** pre-formatted value, e.g. "34.57" or "2–21" */ + value: string; + /** rendered after the value, muted, e.g. "h" or "s" */ + suffix?: string; + /** small mono sub-line */ + sub?: string; + className?: string; +} + +/** + * Static stat tile for docs pages — visually identical to MetricCard but + * server-rendered and string-valued (handles ranges like "2–21 s"). + */ +export default function StatTile({ + label, + value, + suffix, + sub, + className, +}: StatTileProps) { + return ( +
+ + {label} + + + {value} + {suffix && {suffix}} + + {sub && {sub}} +
+ ); +} diff --git a/src/components/docs/TopicBars.tsx b/src/components/docs/TopicBars.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7dfa8bcdac83eb2be647087bfac85b986ba3d3f3 --- /dev/null +++ b/src/components/docs/TopicBars.tsx @@ -0,0 +1,47 @@ +import { fmt } from "@/lib/data"; +import HourBar from "./HourBar"; + +export interface TopicBarsProps { + items: ReadonlyArray<{ label: string; vi?: string; hours: number }>; +} + +/** Horizontal topic-distribution bars (widths relative to the largest topic). */ +export default function TopicBars({ items }: TopicBarsProps) { + const max = Math.max(...items.map((t) => t.hours)); + const total = items.reduce((sum, t) => sum + t.hours, 0); + + return ( +
    + {items.map((t, i) => ( +
  • + + {t.label} + {t.vi && ( + + {t.vi} + + )} + + {/* the dominant domain carries the one quiet phosphor note */} + + + {fmt(t.hours, 1)} + h + + {((t.hours / total) * 100).toFixed(0)}% + + +
  • + ))} +
+ ); +} diff --git a/src/components/docs/TracksTable.tsx b/src/components/docs/TracksTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fe1470b921f5d97984b35f6eccb0ab57a8e890f --- /dev/null +++ b/src/components/docs/TracksTable.tsx @@ -0,0 +1,140 @@ +import clsx from "clsx"; +import Badge from "@/components/ui/Badge"; + +type TrackStatus = "live" | "config-ready" | "roadmap"; + +const STATUS: Record< + TrackStatus, + { label: string; variant: "accent" | "outline" | "ghost" } +> = { + live: { label: "Live", variant: "accent" }, + "config-ready": { label: "Config-ready", variant: "outline" }, + roadmap: { label: "Roadmap", variant: "ghost" }, +}; + +const TRACKS: ReadonlyArray<{ + id: string; + name: string; + rule: string; + status: TrackStatus; +}> = [ + { + id: "A", + name: "Zero-shot", + rule: "The model exactly as released. No dataset-specific signal of any kind — no prompts, no bias lists, no weight updates.", + status: "live", + }, + { + id: "B", + name: "Contextual biasing", + rule: "Bias lists (medical-term hotwords or prompts) allowed at decode time. No weight updates; the bias list is recorded in the run manifest.", + status: "live", + }, + { + id: "C", + name: "Adapted", + rule: "Weights updated using the dataset's frozen Train split only; Valid for tuning. No other in-domain data of any kind.", + status: "live", + }, + { + id: "D", + name: "Open", + rule: "Any external data or method, fully disclosed. The anything-goes track.", + status: "roadmap", + }, + { + id: "E", + name: "Efficiency", + rule: "Deployment-realistic constraints: under 1B parameters, CPU decode, under 8GB VRAM.", + status: "config-ready", + }, + { + id: "F", + name: "Conversational", + rule: "WER + DER on multi-speaker dialogue. Requires conversational corpora (VietMed, clinic data) — the track closest to production.", + status: "roadmap", + }, +]; + +/** Tracks A–F: what each track allows, with launch-status chips. */ +export default function TracksTable() { + return ( +
+ + + + + + + + + + + {TRACKS.map((t, i) => { + const dim = t.status === "roadmap"; + return ( + + + + + + ); + })} + +
+ Evaluation tracks A through F with their rules and launch status +
+ Track + + Allowed signal + + Status +
+ + + {t.id} + + + {t.name} + + + + {t.rule} + + + {STATUS[t.status].label} + +
+
+ ); +} diff --git a/src/components/leaderboard/HeroStats.tsx b/src/components/leaderboard/HeroStats.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e4d83922a9c5768de4b736e3564fd0aafeabf6b --- /dev/null +++ b/src/components/leaderboard/HeroStats.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { ArrowUpRight } from "lucide-react"; + +/* layout effect on the client, plain effect during SSR (where it never + runs) — silences React's useLayoutEffect-on-server warning */ +const useIsoLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +export interface HeroStatsProps { + /** total audio hours, e.g. 34.57 */ + hours: number; + /** utterance count, e.g. 16576 */ + utterances: number; + /** distinct code-switched terms, e.g. 889 */ + csTerms: number; + /** dataset the snapshot belongs to, e.g. "ViMedCSS" */ + datasetName: string; + /** dataset card route, e.g. "/datasets/vimedcss" */ + datasetHref: string; +} + +/** Spring-based easing for smooth, physical feel */ +function springEase(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +/** Shared 0→1 spring ease progress over ~700ms (instant under reduced motion). + Starts at 1 so server markup (and no-JS readers) carry the real values — + the pre-paint layout effect rewinds to 0 before the count-up plays. */ +function useSpringCount(duration = 700): number { + const [progress, setProgress] = useState(1); + const raf = useRef(0); + + useIsoLayoutEffect(() => { + const reduced = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + if (reduced) return; + /* rewind before first paint, then spring up to the real values */ + setProgress(0); + let t0: number | undefined; + const tick = (t: number) => { + t0 ??= t; + const p = Math.min((t - t0) / duration, 1); + setProgress(springEase(p)); + if (p < 1) raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf.current); + }, [duration]); + + return progress; +} + +/** + * Anchor-dataset snapshot band: a bento panel with a scope strip naming the + * dataset the numbers belong to (the hub hosts several datasets, so these + * stats must never read as platform totals), then four stat tiles whose + * numerals spring-count in once on mount. + */ +export default function HeroStats({ + hours, + utterances, + csTerms, + datasetName, + datasetHref, +}: HeroStatsProps) { + const p = useSpringCount(); + + const cells: { value: string; unit?: string; label: string }[] = [ + { value: (hours * p).toFixed(2), unit: "h", label: "audio" }, + { + value: Math.round(utterances * p).toLocaleString("en-US"), + label: "utterances", + }, + { + value: Math.round(csTerms * p).toLocaleString("en-US"), + label: "CS medical terms", + }, + { value: "2–21", unit: "s", label: "clip length" }, + ]; + + return ( +
+ {/* scope strip — these numbers describe the anchor dataset, not the hub */} +
+

+ Anchor dataset snapshot —{" "} + {datasetName} v1 +

+ + Dataset card + + +
+ +
+ {cells.map((c, i) => ( +
+
+ {c.label} +
+
+ {c.value} + {c.unit && ( + {c.unit} + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/leaderboard/LeaderboardTable.tsx b/src/components/leaderboard/LeaderboardTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c616561260c8ffc11689f3db465b441b6bd7211b --- /dev/null +++ b/src/components/leaderboard/LeaderboardTable.tsx @@ -0,0 +1,1013 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowUp, ChevronsUpDown, Search, ShieldCheck } from "lucide-react"; +import { MotionConfig, motion } from "framer-motion"; +import Badge from "@/components/ui/Badge"; +import CIBar from "@/components/ui/CIBar"; +import FlagBadges from "@/components/ui/FlagBadges"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import SplitToggle from "@/components/ui/SplitToggle"; +import { + HIGHER_IS_BETTER, + categoryLabels, + fmt, + metricLabels, + splitLabels, + trackLabels, +} from "@/lib/data"; +import type { + Category, + MetricKey, + ModelRow, + Split, + SplitInfo, + Track, +} from "@/lib/types"; + +export interface LeaderboardTableProps { + models: ModelRow[]; + /** Dataset the board is scoped to, e.g. "ViMedCSS" — shown in the title. */ + datasetName: string; + /** Hard-split profile, for the explanatory note when Hard is selected. */ + hardSplit: SplitInfo; +} + +/* ------------------------------------------------------------------ */ +/* Column + filter configuration */ +/* ------------------------------------------------------------------ */ + +interface MetricCol { + key: MetricKey; + label: string; + title: string; +} + +const METRIC_COLS: MetricCol[] = [ + { key: "wer", label: "WER", title: "Word error rate (%) — lower is better" }, + { + key: "csWer", + label: "CS-WER", + title: "Errors inside code-switched English spans (%) — lower is better", + }, + { + key: "nWer", + label: "N-WER", + title: "WER on Vietnamese-only tokens (%) — lower is better", + }, + { + key: "cer", + label: "CER", + title: "Character error rate (%) — lower is better", + }, + { + key: "mtrExact", + label: "MTR", + title: + "Medical term recall, exact over fuzzy tier (%) — higher is better; sorts by exact", + }, + { + key: "rtfx", + label: "RTFx", + title: "Audio seconds ÷ wall seconds — higher is faster", + }, +]; + +const TRACKS: ("all" | Track)[] = ["all", "A", "B", "C"]; + +const CATEGORY_SHORT: Record = { + "vn-open": "VN open", + "multilingual-open": "Multilingual", + medical: "Medical", + "api-intl": "API · intl", + "api-vn": "API · VN", +}; + +const CATEGORIES: ("all" | Category)[] = [ + "all", + "vn-open", + "multilingual-open", + "medical", + "api-intl", + "api-vn", +]; + +/* ------------------------------------------------------------------ */ +/* Small local pieces */ +/* ------------------------------------------------------------------ */ + +function FilterChip({ + active, + onClick, + title, + children, +}: { + active: boolean; + onClick: () => void; + title?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** 244M / 1.55B from the paramsB field. */ +function fmtParams(b?: number): string { + if (b === undefined) return ""; + return b < 1 ? `${Math.round(b * 1000)}M` : `${b}B`; +} + +/* ------------------------------------------------------------------ */ +/* Table */ +/* ------------------------------------------------------------------ */ + +export default function LeaderboardTable({ + models, + datasetName, + hardSplit, +}: LeaderboardTableProps) { + const [split, setSplit] = useState("test"); + const [track, setTrack] = useState<"all" | Track>("all"); + const [category, setCategory] = useState<"all" | Category>("all"); + const [zone1Only, setZone1Only] = useState(false); + const [query, setQuery] = useState(""); + const [sortKey, setSortKey] = useState("wer"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [showEmptyCols, setShowEmptyCols] = useState(false); + + /* horizontal-scroll affordance: a right-edge fade that lifts once the + table is scrolled to its end (or fits without scrolling) */ + const scrollRef = useRef(null); + const [moreRight, setMoreRight] = useState(false); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const update = () => + setMoreRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 8); + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, []); + + const bestFirstDir = HIGHER_IS_BETTER.has(sortKey) ? "desc" : "asc"; + + function onSort(key: MetricKey) { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(HIGHER_IS_BETTER.has(key) ? "desc" : "asc"); + } + } + + const { scored, unscored } = useMemo(() => { + const q = query.trim().toLowerCase(); + const filtered = models.filter( + (m) => + (track === "all" || m.track === track) && + (category === "all" || m.category === category) && + (!zone1Only || m.flags?.zone1 === true) && + (!q || `${m.name} ${m.org} ${m.slug}`.toLowerCase().includes(q)), + ); + const hasMetric = (m: ModelRow) => + typeof m.metrics?.[split]?.[sortKey] === "number"; + const dir = sortDir === "asc" ? 1 : -1; + const scored = filtered + .filter(hasMetric) + .sort( + (a, b) => + dir * + ((a.metrics![split]![sortKey] as number) - + (b.metrics![split]![sortKey] as number)), + ); + /* unscored order: measured-elsewhere rows, then running, then planned */ + const statusRank = (m: ModelRow) => + m.provenance === "planned" ? 2 : m.provenance === "running" ? 1 : 0; + const unscored = filtered + .filter((m) => !hasMetric(m)) + .sort((a, b) => statusRank(a) - statusRank(b)); + return { scored, unscored }; + }, [models, split, track, category, zone1Only, query, sortKey, sortDir]); + + /** Best-first rank regardless of the current sort direction. */ + const rankOf = (i: number) => + sortDir === bestFirstDir ? i + 1 : scored.length - i; + + /* metric columns with no measured value in the current view: header and + dashes drop to whisper weight so absence never outshouts presence */ + const emptyCols = useMemo(() => { + const rows = [...scored, ...unscored]; + return new Set( + METRIC_COLS.filter( + (c) => + !rows.some((m) => typeof m.metrics?.[split]?.[c.key] === "number"), + ).map((c) => c.key), + ); + }, [scored, unscored, split]); + + /* fully-empty secondary columns collapse into one ghost column by + default (WER / CS-WER, the headline pair, never collapse); the moment + any row carries a value, the column restores automatically */ + const collapsible = METRIC_COLS.filter( + (c) => c.key !== "wer" && c.key !== "csWer" && emptyCols.has(c.key), + ); + const collapsed = !showEmptyCols && collapsible.length > 0; + const visibleCols = collapsed + ? METRIC_COLS.filter((c) => !collapsible.some((e) => e.key === c.key)) + : METRIC_COLS; + const visibleKeys = new Set(visibleCols.map((c) => c.key)); + const collapsedLabels = collapsible.map((c) => c.label).join(", "); + /* 6 fixed columns (#, model, params, track, license, provenance) + + metric columns + the ghost/toggle column when anything is collapsible */ + const colCount = 6 + visibleCols.length + (collapsible.length > 0 ? 1 : 0); + /* undefined = no ghost column · "" = column present but expanded */ + const ghostCell = + collapsible.length > 0 ? (collapsed ? collapsedLabels : "") : undefined; + + return ( + /* reducedMotion="user" makes framer-motion honor prefers-reduced-motion + (transforms freeze, opacity still resolves) — the CSS-side animations + are already covered by the global override in globals.css */ + +
+ {/* ----- title — the board is scoped to one dataset + split ----- */} +

+ Leaderboard — {datasetName}{" "} + + {splitLabels[split].toLowerCase()} split + +

+ + {/* ----- controls ----- */} +
+ + +
+ + Track + + {TRACKS.map((t) => ( + setTrack(t)} + title={t === "all" ? "All tracks" : trackLabels[t]} + > + {t === "all" ? "All" : t} + + ))} +
+ +
+ + Class + + {CATEGORIES.map((c) => ( + setCategory(c)} + title={c === "all" ? "All model classes" : categoryLabels[c]} + > + {c === "all" ? "All" : CATEGORY_SHORT[c]} + + ))} +
+ + + + +
+ + {/* ----- hard-split note ----- */} + {split === "hard" && ( +

+ Hard split —{" "} + {fmt(hardSplit.hours)} h /{" "} + + {hardSplit.utts.toLocaleString("en-US")} + {" "} + utterances of rare or unseen code-switched terms: the generalization + leaderboard. Paper-imported rows have not published Hard-split + numbers, so fewer rows are scored here. +

+ )} + + {/* ----- mobile stacked rows (below md): rank + name, then WER ----- */} +
+
    + {scored.map((m, i) => ( + + ))} +
+ {unscored.length > 0 && ( + <> +

+ Unscored on {splitLabels[split]} — awaiting runs or imports +

+
    + {unscored.map((m) => ( + + ))} +
+ + )} + {scored.length === 0 && unscored.length === 0 && ( +

+ No models match the current filters. +

+ )} +
+ + {/* ----- full table (md and up) ----- + breakout-wide lets the board outgrow the editorial column on xl+ + screens — every column fits without horizontal scroll */} +
+
+ + + + + + + + + + + {visibleCols.map((col) => { + const active = sortKey === col.key; + const empty = emptyCols.has(col.key); + return ( + + ); + })} + {collapsible.length > 0 && ( + + )} + + + + + {scored.map((m, i) => ( + + ))} + + {unscored.length > 0 && ( + + + + )} + {unscored.map((m, i) => ( + + ))} + + {scored.length === 0 && unscored.length === 0 && ( + + + + )} + +
+ Model leaderboard on the {datasetName} {splitLabels[split]}{" "} + split, sorted by {metricLabels[sortKey]} +
+ # + + Model + + Params + + Track + + License + + + + + + Provenance +
+ Unscored on {splitLabels[split]} ·{" "} + {metricLabels[sortKey]} — awaiting runs or imports +
+ No models match the current filters. +
+
+ + {/* right-edge fade: columns continue past the fold — lifts at scroll end */} + + +

+ {scored.length + unscored.length} / {models.length} models ·{" "} + {splitLabels[split]} split · sorted by {metricLabels[sortKey]} +

+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Row */ +/* ------------------------------------------------------------------ */ + +function Row({ + model: m, + split, + rank, + emptyCols, + visibleKeys, + ghostCell, + index = 0, +}: { + model: ModelRow; + split: Split; + rank?: number; + emptyCols: ReadonlySet; + /** metric columns currently rendered (empty ones collapse away) */ + visibleKeys: ReadonlySet; + /** undefined = no ghost column · "" = present but expanded · + non-empty = collapsed, labels for the tooltip */ + ghostCell?: string; + index?: number; +}) { + const s = m.metrics?.[split]; + const isTop = rank === 1; + const running = m.provenance === "running"; + const dimmed = rank === undefined; + const show = (key: MetricKey) => visibleKeys.has(key); + const missing = (key: MetricKey) => ( + + ); + + return ( + + {/* rank + accent rail for #1 · soft accent rail for running rows */} + + {rank ?? "—"} + + + {/* model */} + +
+ + {m.name} + + +
+
{m.org}
+ + + {/* params — split out of the org sub-line into its own column */} + + {m.paramsB !== undefined ? ( + + {fmtParams(m.paramsB)} + + ) : ( + + — + + )} + + + {/* track */} + + + {m.track} + + + + {/* license */} + + {m.license} + + + {/* WER — the headline number, with fixed-scale range bar */} + + {typeof s?.wer === "number" ? ( +
+ + {fmt(s.wer)} + + +
+ ) : ( + missing("wer") + )} + + + {/* CS-WER — the code-switching metric gets the same micro-bar as WER */} + + {typeof s?.csWer === "number" ? ( +
+ + {fmt(s.csWer)} + + +
+ ) : ( + missing("csWer") + )} + + + {/* N-WER */} + {show("nWer") && ( + + {typeof s?.nWer === "number" ? fmt(s.nWer) : missing("nWer")} + + )} + + {/* CER */} + {show("cer") && ( + + {typeof s?.cer === "number" ? fmt(s.cer) : missing("cer")} + + )} + + {/* MTR exact / fuzzy, stacked */} + {show("mtrExact") && ( + + {typeof s?.mtrExact === "number" || + typeof s?.mtrFuzzy === "number" ? ( +
+ + {fmt(s?.mtrExact, 1)} + + + {fmt(s?.mtrFuzzy, 1)} fz + +
+ ) : ( + missing("mtrExact") + )} + + )} + + {/* RTFx */} + {show("rtfx") && ( + + {typeof s?.rtfx === "number" ? ( + <> + {fmt(s.rtfx, 1)} + × + + ) : ( + missing("rtfx") + )} + + )} + + {/* collapsed empty columns: one narrow ghost cell */} + {ghostCell !== undefined && ( + + {ghostCell !== "" && ( + + · · · + + )} + + )} + + {/* provenance */} + + + +
+ ); +} + +/** + * Evidence-gap placeholder: "…" for in-flight runs, a dotted ghost dash for + * paper-imported gaps (footnote [2] — the paper never published the value), + * an em-dash otherwise. `dim` drops fully-empty columns to whisper weight. + */ +function MissingCell({ + running, + provenance, + dim, +}: { + running: boolean; + provenance: ModelRow["provenance"]; + dim?: boolean; +}) { + if (running) { + return ( + + … + + ); + } + if (provenance === "paper-imported") { + return ( + + ); + } + return ( + + — + + ); +} + +/* ------------------------------------------------------------------ */ +/* Mobile row (below md): rank + name, then WER — the headline metric */ +/* stays on screen without horizontal scroll */ +/* ------------------------------------------------------------------ */ + +function MobileRow({ + model: m, + split, + rank, +}: { + model: ModelRow; + split: Split; + rank?: number; +}) { + const s = m.metrics?.[split]; + const isTop = rank === 1; + const running = m.provenance === "running"; + const dimmed = rank === undefined; + + return ( +
  • +
    + + {rank ?? "—"} + + + {m.name} + + +
    +
    +
    +

    + {m.org} + {m.paramsB !== undefined && ( + · {fmtParams(m.paramsB)} + )} +

    +
    + +
    +
    +
    + {typeof s?.wer === "number" ? ( + <> + + {fmt(s.wer)} + + WER + + + + {/* CS-WER — the benchmark's defining metric stays on mobile */} + {typeof s.csWer === "number" && ( + <> + + {fmt(s.csWer)} + + CS-WER + + + + + )} + + ) : ( + + {running ? "…" : "—"} + + )} +
    +
    +
  • + ); +} diff --git a/src/components/model/DatasetScope.tsx b/src/components/model/DatasetScope.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4f446fbe96ffc978e4507c2eb7f7730cef2ebe80 --- /dev/null +++ b/src/components/model/DatasetScope.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import type { DatasetInfo } from "@/lib/types"; + +export interface DatasetScopeProps { + dataset: DatasetInfo; +} + +/** + * Scope bar pinned under the model header: MedASR Bench is a multi-dataset + * hub, so every number on a model page is measured on exactly ONE dataset. + * Today that is the anchor (ViMedCSS); when a second dataset goes live this + * bar is the natural home for a dataset switcher. + */ +export default function DatasetScope({ dataset }: DatasetScopeProps) { + const stats = [ + typeof dataset.hours === "number" ? `${dataset.hours} h` : null, + typeof dataset.utterances === "number" + ? `${dataset.utterances.toLocaleString("en-US")} utts` + : null, + typeof dataset.csTerms === "number" + ? `${dataset.csTerms} CS terms` + : null, + ].filter((s): s is string => s !== null); + + return ( +
    +
    + + Results on + + + {dataset.name} + + {dataset.fullName && ( + {dataset.fullName} + )} + {stats.length > 0 && ( + + {stats.join(" · ")} + + )} +
    + + Dataset profile + + +
    + ); +} diff --git a/src/components/model/FlagNotes.tsx b/src/components/model/FlagNotes.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c762617a6ad303471ae4b73ab0c4b5e83ce74952 --- /dev/null +++ b/src/components/model/FlagNotes.tsx @@ -0,0 +1,79 @@ +import clsx from "clsx"; +import type { ComponentType } from "react"; +import { AlertTriangle, Lock, ShieldCheck, Snowflake } from "lucide-react"; +import type { ModelFlags } from "@/lib/types"; + +export interface FlagNotesProps { + flags?: ModelFlags; + className?: string; +} + +interface Note { + key: keyof ModelFlags; + Icon: ComponentType<{ size?: number | string; className?: string }>; + iconClass: string; + labelClass: string; + label: string; + text: string; +} + +const NOTES: Note[] = [ + { + key: "contamination", + Icon: AlertTriangle, + iconClass: "text-warn", + labelClass: "text-warn", + label: "Contamination", + 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.", + }, + { + key: "referenceOnly", + Icon: Lock, + iconClass: "text-muted", + labelClass: "text-text", + label: "Reference-only", + text: "CC-BY-NC-ND — cannot be fine-tuned or deployed commercially; shown as a yardstick.", + }, + { + key: "frozen", + Icon: Snowflake, + iconClass: "text-muted", + labelClass: "text-text", + label: "Frozen", + text: "the maintaining organization has stopped updates; results are pinned to the last public checkpoint.", + }, + { + key: "zone1", + Icon: ShieldCheck, + iconClass: "text-accent", + labelClass: "text-text", + label: "Zone 1", + text: "self-hostable on a single 24 GB GPU with a license clean for deployment.", + }, +]; + +/** Plain-language explanations for each active flag (pairs with FlagBadges icons). */ +export default function FlagNotes({ flags, className }: FlagNotesProps) { + if (!flags) return null; + const active = NOTES.filter((n) => flags[n.key]); + if (active.length === 0) return null; + + return ( +
      + {active.map(({ key, Icon, iconClass, labelClass, label, text }) => ( +
    • + + + + + {label}{" "} + — {text} + +
    • + ))} +
    + ); +} diff --git a/src/components/model/FrontierThumb.tsx b/src/components/model/FrontierThumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2670630b5a0ec90121004a5102f214cc024af7de --- /dev/null +++ b/src/components/model/FrontierThumb.tsx @@ -0,0 +1,163 @@ +import { + frontierSegments, + niceDomain, + paretoData, + paretoFrontier, +} from "@/components/compare/compare-utils"; +import { fmt } from "@/lib/data"; +import type { ModelRow } from "@/lib/types"; + +export interface FrontierThumbProps { + models: ModelRow[]; + /** slug of the model this page profiles — drawn in accent */ + slug: string; + /** dataset the frontier is computed on, e.g. "ViMedCSS" */ + datasetName?: string; +} + +const W = 232; +const H = 192; +const PAD = 12; + +/** + * "Position on the frontier" thumbnail: the Pareto domain (Test split, + * WER × CS-WER) with this model's dot in accent and every other system + * ghosted. Pure inline SVG — no chart runtime. + */ +export default function FrontierThumb({ + models, + slug, + datasetName, +}: FrontierThumbProps) { + const data = paretoData(models, "test"); + const self = data.find((d) => d.model.slug === slug); + if (!self || data.length < 2) return null; + + const [xLo, xHi] = niceDomain(data.map((d) => d.wer)); + const [yLo, yHi] = niceDomain(data.map((d) => d.csWer)); + const sx = (v: number) => PAD + ((v - xLo) / (xHi - xLo)) * (W - 2 * PAD); + const sy = (v: number) => + PAD + (1 - (v - yLo) / (yHi - yLo)) * (H - 2 * PAD); + + const frontier = paretoFrontier(data); + const segments = frontierSegments(frontier, xHi, yHi); + const onFrontier = frontier.some((d) => d.model.slug === slug); + + return ( + + ); +} diff --git a/src/components/model/GeneralizationStrip.tsx b/src/components/model/GeneralizationStrip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0622d4d542af281564ce75705bf2a36cba15bea7 --- /dev/null +++ b/src/components/model/GeneralizationStrip.tsx @@ -0,0 +1,136 @@ +import clsx from "clsx"; +import { FlaskConical } from "lucide-react"; +import { fmt } from "@/lib/data"; +import type { ModelRow, Provenance, SplitInfo, SplitMetrics } from "@/lib/types"; +import SectionLabel from "./SectionLabel"; + +export interface GeneralizationStripProps { + model: ModelRow; + hardInfo: SplitInfo; +} + +function hasNumbers(m?: SplitMetrics): boolean { + return !!m && Object.values(m).some((v) => typeof v === "number"); +} + +function DeltaTile({ + label, + test, + hard, +}: { + label: string; + test?: number; + hard?: number; +}) { + const d = + typeof test === "number" && typeof hard === "number" ? hard - test : null; + const degrading = d !== null && d > 0; + return ( +
    + + Δ {label} + + + {d === null ? "—" : `${d >= 0 ? "+" : "−"}${Math.abs(d).toFixed(2)}`} + + + {fmt(test)} → {fmt(hard)} + +
    + ); +} + +function GhostTile({ label }: { label: string }) { + return ( +
    + + Δ {label} + + + test → hard +
    + ); +} + +function gapCopy(provenance: Provenance): string { + if (provenance === "running") + return "This model’s pilot run is executing on Test + Hard right now — the delta lands here the moment it commits."; + if (provenance === "planned") + return "A platform run covering Test + Hard is on the roadmap for this model."; + return "Published results cover Test only; the platform pilot re-runs every model on Test + Hard, and this gap closes when its run lands."; +} + +/** + * Test → Hard generalization: signed delta tiles when both splits are + * measured (amber when degrading), otherwise a dashed evidence-gap callout + * with ghost tiles showing the shape of what will appear. + */ +export default function GeneralizationStrip({ + model, + hardInfo, +}: GeneralizationStripProps) { + const test = model.metrics?.test; + const hard = model.metrics?.hard; + const measured = hasNumbers(test) && hasNumbers(hard); + + return ( +
    + + + {measured ? ( +
    +
    + + +
    +

    + The Hard split holds{" "} + {fmt(hardInfo.hours)} + {" "}h of rare and + unseen code-switched terms. A positive Δ (amber) means the model + degrades off the common-term distribution. +

    +
    + ) : ( +
    +
    + + + +
    +

    + Evidence gap — no Hard-split measurement yet +

    +

    + The Hard split holds{" "} + {fmt(hardInfo.hours)} + {" "}h /{" "} + + {hardInfo.utts.toLocaleString("en-US")} + {" "} + utterances of rare and unseen code-switched terms — the + generalization leaderboard. {gapCopy(model.provenance)} +

    +
    +
    +
    + + +
    +
    + )} +
    + ); +} diff --git a/src/components/model/MetricPanel.tsx b/src/components/model/MetricPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd33f82aaef5ed801077864b514ae7301b9648e8 --- /dev/null +++ b/src/components/model/MetricPanel.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import MetricCard from "@/components/ui/MetricCard"; +import SplitToggle from "@/components/ui/SplitToggle"; +import { NORMALIZER_VERSION, splitLabels } from "@/lib/data"; +import type { + ModelRow, + Provenance, + Split, + SplitInfo, + SplitMetrics, +} from "@/lib/types"; +import SectionLabel from "./SectionLabel"; + +export interface MetricPanelProps { + /** dataset these results are scoped to, e.g. "ViMedCSS" — the hub hosts + multiple datasets, so the section header names the one being shown */ + datasetName: string; + metrics?: ModelRow["metrics"]; + splits: { test: SplitInfo; hard: SplitInfo }; + provenance: Provenance; +} + +const VALUE_KEYS = [ + "wer", + "csWer", + "nWer", + "cer", + "mtrExact", + "rtfx", +] as const; + +const METRIC_CHIPS = ["WER", "CS-WER", "N-WER", "CER", "MTR", "RTFx"] as const; + +function emptyNote(provenance: Provenance, split: Split): string { + if (provenance === "running") + return "No committed measurements yet — the pilot run executing now will populate this panel."; + if (provenance === "planned") + return "No measurements yet — this model is on the evaluation roadmap."; + return `No measurements recorded on the ${splitLabels[split]} split yet.`; +} + +/** Per-card microcopy for a missing value, by row provenance. */ +function gapReason(provenance: Provenance): string { + if (provenance === "paper-imported") return "not published"; + if (provenance === "running" || provenance === "planned") + return "awaiting pilot run"; + return "not recorded"; +} + +/** + * Split-toggleable metric grid: WER / CS-WER / N-WER / CER / MTR (exact) / + * RTFx as count-up MetricCards, with 95% CI sub-lines where measured. + */ +export default function MetricPanel({ + datasetName, + metrics, + splits, + provenance, +}: MetricPanelProps) { + const [split, setSplit] = useState("test"); + const m: SplitMetrics | undefined = metrics?.[split]; + const info = splits[split]; + const hasAny = VALUE_KEYS.some((k) => typeof m?.[k] === "number"); + + return ( +
    +
    + + +
    + + {hasAny ? ( + /* Stable keys: toggling splits updates values in place instead of + remounting cards and replaying the one-shot count-up. */ +
    + + + + + + +
    + ) : ( + /* flatlined-ECG empty state: one composed panel instead of a + checkerboard of em-dash cards */ +
    +
    + + +
    +

    + no trace yet — awaiting platform run +

    +

    + {emptyNote(provenance, split)} +

    +
      + {METRIC_CHIPS.map((c) => ( +
    • + {c} +
    • + ))} +
    +
    + )} + +

    + WER family in %, lower is better · MTR higher is better · RTFx = audio + s ÷ wall s, higher is faster · normalizer {NORMALIZER_VERSION} +

    +
    + ); +} diff --git a/src/components/model/ModelHeader.tsx b/src/components/model/ModelHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0b52c60a34182eff5760ba6d0cf34826208d075 --- /dev/null +++ b/src/components/model/ModelHeader.tsx @@ -0,0 +1,66 @@ +import Badge from "@/components/ui/Badge"; +import FlagBadges from "@/components/ui/FlagBadges"; +import ProvenanceChip from "@/components/ui/ProvenanceChip"; +import { categoryLabels, trackLabels } from "@/lib/data"; +import type { ModelRow } from "@/lib/types"; +import FlagNotes from "./FlagNotes"; + +export interface ModelHeaderProps { + model: ModelRow; +} + +/** "0.6" → "0.6B"; undefined → "—". */ +function fmtParams(paramsB?: number): string { + return paramsB === undefined ? "—" : `${paramsB}B`; +} + +/** + * Model identity block: serif name, org, mono param count, license / + * track chips, provenance chip, flag icons + plain-language flag notes, + * and the editorial note line. + */ +export default function ModelHeader({ model }: ModelHeaderProps) { + return ( +
    +

    + Model profile · {categoryLabels[model.category]} +

    + +
    +

    + {model.name} +

    +
    + +
    +
    + +
    + {model.org} + + · + + + {fmtParams(model.paramsB)} + + + {model.license} + + + {trackLabels[model.track]} + + +
    + + {model.note && ( +

    + {model.note} +

    + )} + +
    + +
    +
    + ); +} diff --git a/src/components/model/RunManifest.tsx b/src/components/model/RunManifest.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff4f5549e03e7b681bd37bc8e213217881d2f8dd --- /dev/null +++ b/src/components/model/RunManifest.tsx @@ -0,0 +1,86 @@ +import { + HARNESS_VERSION, + NORMALIZER_VERSION, + getDataset, + provenanceLabels, +} from "@/lib/data"; +import type { ModelRow } from "@/lib/types"; +import SectionLabel from "./SectionLabel"; + +export interface RunManifestProps { + model: ModelRow; + /** section index — unmeasured pages promote the manifest up the page */ + index?: string; +} + +/** + * Mono manifest block on surface-2: platform constants (normalizer, + * harness), decode-config placeholders, date stamp and source — unknown + * fields dash out. + */ +export default function RunManifest({ model, index = "05" }: RunManifestProps) { + /* anchor dataset (ViMedCSS) — live, so split stats exist; guard anyway + since DatasetInfo carries them optionally on the multi-dataset hub */ + const dataset = getDataset(); + const splits = dataset.splits; + + const source = + model.provenance === "paper-imported" + ? `${provenanceLabels[model.provenance]} · arXiv:${dataset.arxiv}` + : provenanceLabels[model.provenance]; + + const measuredSplits = (["test", "hard"] as const).filter((s) => + Object.values(model.metrics?.[s] ?? {}).some((v) => typeof v === "number"), + ); + const splitsValue = + model.provenance === "running" + ? "test + hard" + : measuredSplits.length > 0 + ? measuredSplits.join(" + ") + : "—"; + + const fields: [string, string][] = [ + ["normalizer", NORMALIZER_VERSION], + ["harness", HARNESS_VERSION], + ["decode_config", "—"], + ["checkpoint", "—"], + ["run_date", model.provenance === "running" ? "in progress" : "—"], + ["source", source], + [ + "dataset", + splits + ? `${dataset.name} · test ${splits.test.hours} h / hard ${splits.hard.hours} h` + : dataset.name, + ], + ["splits", splitsValue], + ]; + + return ( +
    + +
    +
    + {fields.map(([key, value]) => ( +
    +
    {key}
    +
    + {value} +
    +
    + ))} +
    +

    + WER is comparable only within a normalizer version · bootstrap 95% + CIs on headline metrics. +

    +
    +
    + ); +} diff --git a/src/components/model/SectionLabel.tsx b/src/components/model/SectionLabel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f4832e67dfd49c71830029beccfb14f81070a54 --- /dev/null +++ b/src/components/model/SectionLabel.tsx @@ -0,0 +1,36 @@ +import clsx from "clsx"; + +export interface SectionLabelProps { + /** mono index, e.g. "02" */ + index: string; + title: string; + /** optional mono hint rendered after the title */ + hint?: string; + className?: string; +} + +/** Compact numbered section header for model-detail sections. */ +export default function SectionLabel({ + index, + title, + hint, + className, +}: SectionLabelProps) { + return ( +
    + + {index} + +

    {title}

    + {hint && {hint}} +
    + ); +} diff --git a/src/components/model/StatusNotice.tsx b/src/components/model/StatusNotice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62872d111afb5986348ccbf8b471c4427d9719f6 --- /dev/null +++ b/src/components/model/StatusNotice.tsx @@ -0,0 +1,57 @@ +import type { ModelRow } from "@/lib/types"; + +export interface StatusNoticeProps { + model: ModelRow; + /** dataset the run is scoped to, e.g. "ViMedCSS" */ + datasetName?: string; +} + +/** + * Live-status block for `running` rows (pulsing dot, executing now) and a + * roadmap note for `planned` rows. Renders nothing for measured rows. + */ +export default function StatusNotice({ + model, + datasetName, +}: StatusNoticeProps) { + if (model.provenance === "running") { + return ( +
    +
    + +

    + Live — executing {datasetName ? `${datasetName} ` : ""}Test + Hard + on this platform now +

    +
    +

    + Pilot harness run in progress. Headline metrics, bootstrap 95% CIs + and the run manifest will populate the moment results commit — + provenance then flips to platform-verified. +

    +
    + ); + } + + if (model.provenance === "planned") { + const isApi = model.category === "api-intl" || model.category === "api-vn"; + return ( +
    +

    + Roadmap — evaluation planned +

    +

    + This model is queued on the evaluation roadmap and has not been run + yet. + {isApi && + " Commercial APIs are benchmarked as date-stamped snapshots, with quarterly drift re-runs planned."} +

    +
    + ); + } + + return null; +} diff --git a/src/components/model/TopicBreakdown.tsx b/src/components/model/TopicBreakdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e3f496c77f682f41efbeccc46613a1259824abeb --- /dev/null +++ b/src/components/model/TopicBreakdown.tsx @@ -0,0 +1,95 @@ +import type { ModelRow, Provenance, SplitMetrics } from "@/lib/types"; +import SectionLabel from "./SectionLabel"; +import TopicChart, { type TopicWer } from "./TopicChart"; + +export interface TopicBreakdownProps { + model: ModelRow; +} + +/** The five ViMedCSS topic domains (ghost state when no breakdown exists). */ +const TOPICS = [ + "Medical Sciences", + "Pathology & Pathogens", + "Treatments", + "Nutrition", + "Diagnostics", +]; + +/** + * Per-topic data is an optional forward-compatible extension of + * SplitMetrics (`topics: [{ topic, wer }]`) written by platform runs; + * paper imports never carry it, so we runtime-guard the shape. + */ +function readTopics(metrics?: ModelRow["metrics"]): TopicWer[] | null { + for (const split of ["test", "hard"] as const) { + const m = metrics?.[split] as + | (SplitMetrics & { topics?: unknown }) + | undefined; + const t = m?.topics; + if ( + Array.isArray(t) && + t.length > 0 && + t.every( + (x): x is TopicWer => + typeof x === "object" && + x !== null && + typeof (x as { topic?: unknown }).topic === "string" && + typeof (x as { wer?: unknown }).wer === "number", + ) + ) { + return t; + } + } + return null; +} + +function gapReason(provenance: Provenance): string { + if (provenance === "running") + return "the pilot run executing now records it per domain"; + if (provenance === "planned") return "no run exists yet"; + if (provenance === "paper-imported") + return "paper imports report corpus-level scores only"; + return "this submission did not include a per-domain breakdown"; +} + +/** + * Per-topic WER section: recharts horizontal bars when a run recorded the + * breakdown, otherwise an evidence-gap mini-note listing the five domains. + */ +export default function TopicBreakdown({ model }: TopicBreakdownProps) { + const topics = readTopics(model.metrics); + + return ( +
    + + + {topics ? ( +
    + +
    + ) : ( +
    +

    + Per-topic WER is unavailable for this row —{" "} + {gapReason(model.provenance)}. Platform-verified runs break WER + down across the five ViMedCSS domains: +

    +
      + {TOPICS.map((t) => ( +
    • + {t} +
    • + ))} +
    +
    + )} +
    + ); +} diff --git a/src/components/model/TopicChart.tsx b/src/components/model/TopicChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a63dbd660590a668110fd6c00d2471037bdd15e0 --- /dev/null +++ b/src/components/model/TopicChart.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +export interface TopicWer { + topic: string; + wer: number; +} + +export interface TopicChartProps { + data: TopicWer[]; +} + +/** + * Horizontal per-topic WER bar chart. Rendered only when a run actually + * recorded a per-topic breakdown (platform-verified runs). + * Colors tuned for the lifted palette: category labels in full text ink, + * brighter accent bars. The one-shot bar draw-in is gated on + * prefers-reduced-motion (recharts animation does not read the CSS override). + */ +export default function TopicChart({ data }: TopicChartProps) { + /* lazy init: only meaningful client-side — ResponsiveContainer measures + on mount anyway, so there is no server-rendered chart to mismatch */ + const [animate] = useState( + () => + typeof window !== "undefined" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + + return ( +
    `${d.topic} ${d.wer.toFixed(2)} percent`) + .join(", ")}`} + style={{ height: data.length * 44 + 40 }} + > + + + + + + [`${Number(value).toFixed(2)} %`, "WER"]} + /> + + + +
    + ); +} diff --git a/src/components/ui/AmbientEffects.tsx b/src/components/ui/AmbientEffects.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1dbb92dffaa6a9c3467c7ab58b3b83cf940b6190 --- /dev/null +++ b/src/components/ui/AmbientEffects.tsx @@ -0,0 +1,23 @@ +"use client"; + +/** + * AmbientEffects — fixed, full-viewport phosphor light washes that sit + * BELOW all content (negative z-index; body's background is transparent so + * they shine through the graph-paper grid painted on ). + * + * GPU-cheap: each layer is a static radial-gradient drifted with a very slow + * transform-only CSS animation (`ambient-drift-a/b` in globals.css). + * Under prefers-reduced-motion the drift stops (globals.css sets + * `.ambient-glow { animation: none }`) and only the static glow remains. + */ +export default function AmbientEffects() { + return ( +
    +
    +
    +
    + ); +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4a4d0a88f13b06487a4da26e5875228f36a2d0ff --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,154 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { motion } from "framer-motion"; + +export type BadgeVariant = + | "neutral" + | "accent" + | "warn" + | "crit" + | "outline" + | "dashed" + | "ghost"; + +export interface BadgeProps { + children: ReactNode; + variant?: BadgeVariant; + className?: string; + title?: string; + /** Enable subtle pulse animation */ + pulse?: boolean; + /** Enable shimmer effect (for live/loading states) */ + shimmer?: boolean; +} + +const variants: Record = { + neutral: "bg-surface-2 text-muted border border-line", + accent: "bg-accent-dim text-accent border border-accent/25", + warn: "bg-warn-dim text-warn border border-warn/25", + crit: "bg-crit-dim text-crit border border-crit/25", + outline: "bg-transparent text-text border border-line", + dashed: "bg-transparent text-muted border border-dashed border-line", + ghost: "bg-transparent text-muted border border-line/50", +}; + +/** + * Badge - Small mono-set chip with enhanced micro-interactions. + * Features subtle animations and hover effects. + */ +export default function Badge({ + children, + variant = "neutral", + className, + title, + pulse = false, + shimmer = false, +}: BadgeProps) { + return ( + + {/* Pulse dot for live states */} + {pulse && ( + + )} + + {/* Shimmer overlay */} + {shimmer && ( + + )} + + {/* Content */} + {children} + + ); +} + +/** + * BadgeGroup - Horizontal group of badges with stagger animation + */ +interface BadgeGroupProps { + badges: { label: string; variant?: BadgeVariant; title?: string; pulse?: boolean }[]; + className?: string; +} + +export function BadgeGroup({ badges, className }: BadgeGroupProps) { + return ( + + {badges.map((badge, i) => ( + + + {badge.label} + + + ))} + + ); +} diff --git a/src/components/ui/CIBar.tsx b/src/components/ui/CIBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0620b56dec96d90c123bded3a9242b96cdd1ccfd --- /dev/null +++ b/src/components/ui/CIBar.tsx @@ -0,0 +1,71 @@ +import clsx from "clsx"; + +export interface CIBarProps { + /** point estimate (e.g. WER) */ + value: number; + /** optional 95% CI [lo, hi] — rendered as the whisker range */ + ci?: [number, number]; + /** scale floor, default 15 (fixed WER scale) */ + min?: number; + /** scale ceiling, default 65 */ + max?: number; + className?: string; +} + +/** + * Inline CI whisker micro-bar on a fixed scale (default 15–65 WER). + * A dim track with faint scale end ticks, an accent whisker spanning the + * CI (minimum 4px so tight CIs stay visible) with end-cap ticks, and a + * tick at the point estimate — all drawn in left-to-right on first paint. + * Decorative (aria-hidden) — always pair with the numeric value. + */ +export default function CIBar({ + value, + ci, + min = 15, + max = 65, + className, +}: CIBarProps) { + const pct = (x: number) => + `${(Math.min(Math.max((x - min) / (max - min), 0), 1) * 100).toFixed(2)}%`; + + return ( + + {/* track + scale end ticks: the bar reads as a 15–65 scale even + before any CI exists */} + + + + {/* whisker + point tick: one-shot scaleX draw-in from the left */} + + {ci && ( + <> + + + + + )} + {/* point tick */} + + + + ); +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20451f9690458fbf83f6fa04eb2cac565e8d5f2a --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,183 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { useCallback, useRef } from "react"; +import { motion } from "framer-motion"; + +export interface CardProps { + children: ReactNode; + className?: string; + /** Enable spotlight hover effect */ + spotlight?: boolean; + /** Glass morphism intensity */ + glass?: "none" | "subtle" | "medium" | "strong"; + /** Depth level for layered shadows */ + depth?: 0 | 1 | 2 | 3; + /** Hover lift effect */ + hoverLift?: boolean; + /** Animated entrance */ + animate?: boolean; + /** Entrance animation delay */ + delay?: number; +} + +/** + * Card - Translucent surface with hairline border. + * Features spotlight illumination, glass morphism, and depth effects. + */ +export default function Card({ + children, + className, + spotlight = true, + glass = "none", + depth = 1, + hoverLift = true, + animate = false, + delay = 0, +}: CardProps) { + const cardRef = useRef(null); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!cardRef.current || !spotlight) return; + const rect = cardRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + cardRef.current.style.setProperty("--mouse-x", `${x}%`); + cardRef.current.style.setProperty("--mouse-y", `${y}%`); + }, [spotlight]); + + const glassClasses = { + none: "", + subtle: "bg-surface/60 backdrop-blur-sm", + medium: "bg-surface/80 backdrop-blur-md", + strong: "bg-surface/95 backdrop-blur-lg", + }; + + const depthClasses = { + 0: "", + 1: "depth-1", + 2: "depth-2", + 3: "depth-3", + }; + + const cardContent = ( +
    + {/* Inner glow at top edge */} +
    + + {/* Content */} +
    {children}
    +
    + ); + + if (animate) { + return ( + + {cardContent} + + ); + } + + return cardContent; +} + +/** + * InteractiveCard - Card with enhanced hover/tap interactions + */ +export interface InteractiveCardProps extends CardProps { + onClick?: () => void; + href?: string; +} + +export function InteractiveCard({ + children, + className, + spotlight = true, + glass = "none", + depth = 1, + hoverLift = true, + onClick, + href, +}: InteractiveCardProps) { + // Use state to track mouse position for spotlight effect + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!spotlight) return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + e.currentTarget.style.setProperty("--mouse-x", `${x}%`); + e.currentTarget.style.setProperty("--mouse-y", `${y}%`); + }, [spotlight]); + + const glassClasses = { + none: "", + subtle: "bg-surface/60 backdrop-blur-sm", + medium: "bg-surface/80 backdrop-blur-md", + strong: "bg-surface/95 backdrop-blur-lg", + }; + + const depthClasses = { + 0: "", + 1: "depth-1", + 2: "depth-2", + 3: "depth-3", + }; + + // Use motion.div for consistent typing, with conditional href on anchor + return ( + + {/* Inner glow */} +
    + + {/* Hover accent line */} + + + {/* Content */} + {href ? ( + {children} + ) : ( +
    {children}
    + )} +
    + ); +} diff --git a/src/components/ui/DatasetChips.tsx b/src/components/ui/DatasetChips.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9f1431db0f0eb46452cda2150e64d540de44af3 --- /dev/null +++ b/src/components/ui/DatasetChips.tsx @@ -0,0 +1,77 @@ +import clsx from "clsx"; +import Link from "next/link"; + +/* DatasetChips — chip strip for the multi-dataset era. + Server-component friendly: no client JS, all micro-interactions are CSS. + Live datasets link to /datasets/; coming-soon datasets render as + muted, non-interactive chips with a "soon" suffix. */ + +export interface DatasetChipsProps { + datasets: Array<{ + id: string; + name: string; + status: "live" | "coming-soon"; + }>; + /** id of the dataset whose chip renders accent-tinted */ + activeId?: string; + className?: string; +} + +const chipBase = + "num inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-sm border px-2.5 py-1 text-[11px] leading-4 tracking-wide"; + +export default function DatasetChips({ + datasets, + activeId, + className, +}: DatasetChipsProps) { + return ( + + ); +} diff --git a/src/components/ui/FlagBadges.tsx b/src/components/ui/FlagBadges.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57c8b91d8d7edf7b69689280f9bff9ad4a5efa8d --- /dev/null +++ b/src/components/ui/FlagBadges.tsx @@ -0,0 +1,54 @@ +import clsx from "clsx"; +import { AlertTriangle, Lock, ShieldCheck, Snowflake } from "lucide-react"; +import type { ModelFlags } from "@/lib/types"; + +export interface FlagBadgesProps { + flags?: ModelFlags; + className?: string; +} + +/** + * 14px lucide flag icons with title tooltips: + * contamination = amber AlertTriangle · referenceOnly = Lock · + * frozen = Snowflake · zone1 = accent ShieldCheck. + * Renders nothing when no flags are set. + */ +export default function FlagBadges({ flags, className }: FlagBadgesProps) { + if (!flags) return null; + const { contamination, referenceOnly, frozen, zone1 } = flags; + if (!contamination && !referenceOnly && !frozen && !zone1) return null; + + return ( + + {contamination && ( + + + + )} + {referenceOnly && ( + + + + )} + {frozen && ( + + + + )} + {zone1 && ( + + + + )} + + ); +} diff --git a/src/components/ui/GlassCard.tsx b/src/components/ui/GlassCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c6a42f058564267c01409b2d6d3f1202ff2273d --- /dev/null +++ b/src/components/ui/GlassCard.tsx @@ -0,0 +1,133 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; + +/* Glass primitives — thin wrappers over the `.glass` / `.glass-veil` + utilities in globals.css, tuned to the lifted palette. The blur/veil comes + from CSS so every instance stays consistent; intensity only modulates how + opaque the surface reads. Hover motion is CSS-only (.hover-lift), so the + global prefers-reduced-motion override collapses it for free. */ + +export interface GlassCardProps { + children: ReactNode; + className?: string; + /** Intensity of the glass effect (how opaque the surface reads) */ + intensity?: "subtle" | "medium" | "strong"; + /** Enable hover lift effect */ + hoverLift?: boolean; + /** Enable spotlight effect */ + spotlight?: boolean; + /** Padding size */ + padding?: "none" | "sm" | "md" | "lg"; + /** On click handler */ + onClick?: () => void; +} + +const paddingClasses = { + none: "", + sm: "p-3", + md: "p-5", + lg: "p-6", +} as const; + +/* layered ON TOP of .glass; later-in-cascade Tailwind bg utilities win */ +const intensityClasses = { + subtle: "bg-surface/55", + medium: "", // .glass default (surface @ 72%) + strong: "bg-surface/90", +} as const; + +/** + * GlassCard — translucent panel: backdrop blur + hairline + machined + * top-edge relief (the `.glass` utility), with optional lift + spotlight. + */ +export default function GlassCard({ + children, + className, + intensity = "medium", + hoverLift = true, + spotlight = false, + padding = "md", + onClick, +}: GlassCardProps) { + return ( +
    + {/* phosphor top-edge glint */} +
    + +
    {children}
    + + {/* optional corner light that wakes on hover */} + {spotlight && ( +
    + )} +
    + ); +} + +/** + * GlassSurface — full-area glass region (bg-tinted veil, lighter border); + * for wrapping whole sections rather than single cards. + */ +export interface GlassSurfaceProps { + children: ReactNode; + className?: string; + intensity?: "subtle" | "medium" | "strong"; +} + +export function GlassSurface({ + children, + className, + intensity = "subtle", +}: GlassSurfaceProps) { + const surfaceIntensity = { + subtle: "bg-bg/40 backdrop-blur-sm", + medium: "bg-bg/60 backdrop-blur-md", + strong: "bg-bg/80 backdrop-blur-lg", + } as const; + + return ( +
    + {children} +
    + ); +} + +/** + * GlassPanel — nested glass container for complex layouts. + */ +export interface GlassPanelProps { + children: ReactNode; + className?: string; +} + +export function GlassPanel({ children, className }: GlassPanelProps) { + return ( +
    + {children} +
    + ); +} diff --git a/src/components/ui/LoadingSkeleton.tsx b/src/components/ui/LoadingSkeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a4e638869c8dd1e012da7b82b367b408f376441 --- /dev/null +++ b/src/components/ui/LoadingSkeleton.tsx @@ -0,0 +1,159 @@ +import clsx from "clsx"; + +/* Server-component-friendly skeleton primitives for route loading.tsx files. + All shimmer comes from the `.skeleton` CSS class in globals.css (the + existing `shimmer` keyframes) — no client JS, no framer-motion, and the + global prefers-reduced-motion override collapses it to a static block. */ + +export interface SkeletonProps { + className?: string; + /** CSS width, e.g. "60%" or 120 (px) */ + width?: string | number; + /** CSS height, e.g. 16 (px) or "2rem" */ + height?: string | number; +} + +/** + * Skeleton — a single shimmering placeholder block. + * Size it via width/height props or Tailwind classes (h-4 w-32 …). + */ +export function Skeleton({ className, width, height }: SkeletonProps) { + return ( +
    + ); +} + +export interface SkeletonTextProps { + className?: string; + /** number of text lines, default 3 */ + lines?: number; + /** width of the trailing line, default "60%" */ + lastLineWidth?: string; +} + +/** + * SkeletonText — a stack of shimmering text lines; the last line is shorter + * so the block reads like a paragraph. + */ +export function SkeletonText({ + className, + lines = 3, + lastLineWidth = "60%", +}: SkeletonTextProps) { + return ( +
    + {Array.from({ length: lines }).map((_, i) => ( + + ))} +
    + ); +} + +export interface SkeletonTableProps { + className?: string; + /** body rows, default 8 */ + rows?: number; + /** columns, default 6 */ + columns?: number; +} + +/** + * SkeletonTable — header strip + hairline-separated rows; placeholder for + * leaderboard-style tables while route data streams in. + */ +export function SkeletonTable({ + className, + rows = 8, + columns = 6, +}: SkeletonTableProps) { + return ( +
    + {/* header */} +
    + {Array.from({ length: columns }).map((_, i) => ( + + ))} +
    + {/* rows */} + {Array.from({ length: rows }).map((_, r) => ( +
    + {Array.from({ length: columns }).map((_, c) => ( + + ))} +
    + ))} +
    + ); +} + +export interface SkeletonCardProps { + className?: string; + /** body text lines, default 2 */ + lines?: number; +} + +/** + * SkeletonCard — hairline card with a heading line + body text placeholder. + */ +export function SkeletonCard({ className, lines = 2 }: SkeletonCardProps) { + return ( +
    + + +
    + ); +} + +export interface SkeletonStatsProps { + className?: string; + /** number of stat tiles, default 4 */ + count?: number; +} + +/** + * SkeletonStats — grid of MetricCard-shaped stat tiles (big number + label). + * Compose grid columns via className (e.g. "grid-cols-2 md:grid-cols-4"). + */ +export function SkeletonStats({ className, count = 4 }: SkeletonStatsProps) { + return ( +
    + {Array.from({ length: count }).map((_, i) => ( +
    + + +
    + ))} +
    + ); +} diff --git a/src/components/ui/MetricCard.tsx b/src/components/ui/MetricCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..278f45f329c3a6aad2a1a63fd3657c5138e33353 --- /dev/null +++ b/src/components/ui/MetricCard.tsx @@ -0,0 +1,143 @@ +"use client"; + +import clsx from "clsx"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import CIBar from "@/components/ui/CIBar"; +import { fmt, fmtCI } from "@/lib/data"; + +/* layout effect on the client, plain effect during SSR (where it never + runs) — silences React's useLayoutEffect-on-server warning */ +const useIsoLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +export interface MetricCardProps { + label: string; + value?: number; + /** decimal places, default 2 */ + digits?: number; + /** rendered after the number, e.g. "%" or "×" */ + suffix?: string; + /** optional 95% CI rendered as a sub-line */ + ci?: [number, number]; + /** why the value is missing, e.g. "not published" / "awaiting pilot run" — + switches the card to a quiet dashed evidence-gap state */ + gapReason?: string; + className?: string; +} + +/** Spring-based easing for smooth, physical feel */ +function springEase(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +/** + * Stat tile: muted label over a large IBM Plex Mono value that springs in + * ~700ms on mount via requestAnimationFrame (skipped under + * prefers-reduced-motion), plus an optional CI sub-line. + * Spotlight illumination follows cursor on hover. + */ +export default function MetricCard({ + label, + value, + digits = 2, + suffix, + ci, + gapReason, + className, +}: MetricCardProps) { + /* animation progress 0→1; the shown number is value * progress. + Starts at 1 so server markup (and no-JS readers) carry the real value — + the pre-paint layout effect below rewinds to 0 before the count-up. */ + const [progress, setProgress] = useState(1); + const raf = useRef(0); + const cardRef = useRef(null); + /* the count-up is part of the one-shot page-load orchestration: + it plays once on mount — later value changes update in place */ + const played = useRef(false); + + useIsoLayoutEffect(() => { + if (value === undefined) return; + if (played.current) { + setProgress(1); + return; + } + played.current = true; + const reduced = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + if (reduced) return; + /* rewind before first paint, then spring up to the real value */ + setProgress(0); + const duration = 700; + let t0: number | undefined; + const tick = (t: number) => { + t0 ??= t; + const p = Math.min((t - t0) / duration, 1); + setProgress(springEase(p)); + if (p < 1) raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf.current); + }, [value]); + + /* Spotlight effect on hover */ + const handleMouseMove = (e: React.MouseEvent) => { + if (!cardRef.current || value === undefined) return; + const rect = cardRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + cardRef.current.style.setProperty("--mouse-x", `${x}%`); + cardRef.current.style.setProperty("--mouse-y", `${y}%`); + }; + + /* Evidence-gap state: dashed quiet card instead of a full-weight dash */ + if (value === undefined) { + return ( +
    + + {label} + + + — + + {gapReason && ( + + {gapReason} + + )} +
    + ); + } + + return ( +
    + + {label} + + + {fmt(value * progress, digits)} + {suffix && ( + {suffix} + )} + + {ci && ( + + 95% CI {fmtCI(ci, digits)} + + + )} +
    + ); +} diff --git a/src/components/ui/PageTransition.tsx b/src/components/ui/PageTransition.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d741a299a479f63007d393eba323bd99d7684802 --- /dev/null +++ b/src/components/ui/PageTransition.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +import { usePathname } from "next/navigation"; + +const EASE = [0.22, 1, 0.36, 1] as const; + +export interface PageTransitionProps { + children: React.ReactNode; + className?: string; +} + +/** + * PageTransition — smooth fade + rise entrance on every route change. + * + * Keyed by pathname so it re-runs even when the root template does not + * remount (Next only remounts a root template when the FIRST segment + * changes — deeper navigations like /models/a → /models/b keep it mounted). + * + * No AnimatePresence/exit animation: the App Router swaps the outgoing tree + * immediately, so an entrance-only transition stays glitch-free and never + * blocks scroll restoration. + * + * Reduced motion: the same tree renders (no hydration mismatch) but the + * transition resolves instantly via duration 0. + */ +export default function PageTransition({ + children, + className, +}: PageTransitionProps) { + const pathname = usePathname(); + const reduced = useReducedMotion(); + + return ( + + {children} + + ); +} diff --git a/src/components/ui/ProvenanceChip.tsx b/src/components/ui/ProvenanceChip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d51e5ea11d7dff205df95655b8292078f7f669bd --- /dev/null +++ b/src/components/ui/ProvenanceChip.tsx @@ -0,0 +1,48 @@ +import clsx from "clsx"; +import type { Provenance } from "@/lib/types"; +import { provenanceLabels } from "@/lib/data"; + +export interface ProvenanceChipProps { + provenance: Provenance; + className?: string; +} + +const styles: Record = { + "platform-verified": "bg-accent-dim text-accent border border-accent/30", + "api-snapshot": "bg-surface-2 text-text border border-line", + "self-reported": "bg-transparent text-text border border-line", + "paper-imported": "bg-transparent text-muted border border-dashed border-line", + running: "chip-shimmer text-accent border border-accent/20", + planned: "bg-transparent text-muted border border-line/50", +}; + +/** + * Provenance class chip: + * platform-verified = solid accent-tinted · api-snapshot = neutral solid + * (we ran it, but it's a dated vendor snapshot, not a pinned-revision + * platform run) · self-reported = outline · paper-imported = dashed border · + * running = shimmer + pulse dot · planned = dim ghost. + */ +export default function ProvenanceChip({ + provenance, + className, +}: ProvenanceChipProps) { + return ( + + {provenance === "running" && ( + + )} + {provenanceLabels[provenance]} + + ); +} diff --git a/src/components/ui/PulseLine.tsx b/src/components/ui/PulseLine.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9a0e242292a56e51a224375f39d06707c6dfc710 --- /dev/null +++ b/src/components/ui/PulseLine.tsx @@ -0,0 +1,66 @@ +import clsx from "clsx"; + +export interface PulseLineProps { + className?: string; + /** 24px-tall section-divider variant (default is the 48px hero trace) */ + compact?: boolean; +} + +const TRACE = + "M0 30 H96 L106 30 L112 24 L120 30 H180 L190 30 L198 8 L206 44 L214 30 H320 L330 27 L338 30 H430 L440 30 L448 6 L456 45 L464 30 H580 L590 26 L598 30 H672 L680 30 L688 12 L696 40 L702 30 H800"; + +/** + * Signature ECG pulse trace. Inline SVG, --accent stroke. Three layers: + * a blurred phosphor glow, the dim base path that draws itself once on + * load (pathLength=1 + .draw-line), and a short bright tracer sweeping + * forever (.pulse-sweep — hidden under prefers-reduced-motion). + * Decorative: aria-hidden, stretches to container width. + */ +export default function PulseLine({ className, compact }: PulseLineProps) { + return ( + + {/* phosphor glow under-layer */} + + {/* dim base path: one-shot draw on load */} + + {/* bright tracer: infinite slow sweep */} + + + ); +} diff --git a/src/components/ui/ScrollReveal.tsx b/src/components/ui/ScrollReveal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d1a41d657d566d1a7ec91c49e58c7404e0018ddf --- /dev/null +++ b/src/components/ui/ScrollReveal.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; +import clsx from "clsx"; + +/* The ONE scroll-reveal API for the platform (IntersectionObserver based). + It toggles the .scroll-* / .in-view classes defined in globals.css, so the + global prefers-reduced-motion override resolves everything instantly. */ + +export type RevealDirection = + | "up" + | "down" + | "left" + | "right" + | "fade" + | "scale"; + +const DIRECTION_CLASS: Record = { + up: "scroll-animate", + down: "scroll-slide-down", + left: "scroll-slide-left", + right: "scroll-slide-right", + fade: "scroll-fade", + scale: "scroll-scale", +}; + +function useInViewClass( + ref: React.RefObject, + { delay, threshold, once }: { delay: number; threshold: number; once: boolean }, +) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + /* reduced motion: reveal immediately, no observer churn */ + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + element.classList.add("in-view"); + return; + } + + let timer: ReturnType | undefined; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + if (delay > 0) { + timer = setTimeout( + () => element.classList.add("in-view"), + delay * 1000, + ); + } else { + element.classList.add("in-view"); + } + if (once) observer.unobserve(element); + } else if (!once) { + element.classList.remove("in-view"); + } + }, + { threshold, rootMargin: "-50px" }, + ); + + observer.observe(element); + return () => { + observer.disconnect(); + if (timer) clearTimeout(timer); + }; + }, [ref, delay, threshold, once]); +} + +export interface ScrollRevealProps { + children: ReactNode; + className?: string; + /** entrance direction, default "up" */ + direction?: RevealDirection; + /** delay in seconds before revealing once in view */ + delay?: number; + /** IntersectionObserver threshold, default 0.1 */ + threshold?: number; + /** reveal only the first time, default true */ + once?: boolean; +} + +/** + * ScrollReveal — reveals children with a fade/slide when scrolled into view. + */ +export default function ScrollReveal({ + children, + className, + direction = "up", + delay = 0, + threshold = 0.1, + once = true, +}: ScrollRevealProps) { + const ref = useRef(null); + useInViewClass(ref, { delay, threshold, once }); + + return ( +
    + {children} +
    + ); +} + +export interface StaggerProps { + children: ReactNode; + className?: string; + /** per-child delay in milliseconds, default 60 */ + staggerDelay?: number; + /** IntersectionObserver threshold, default 0.1 */ + threshold?: number; +} + +/** + * Stagger — container whose direct children rise in sequence when the + * container scrolls into view (uses .scroll-stagger from globals.css). + */ +export function Stagger({ + children, + className, + staggerDelay = 60, + threshold = 0.1, +}: StaggerProps) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + element.classList.add("in-view"); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + element + .querySelectorAll(":scope > *") + .forEach((child, index) => { + (child as HTMLElement).style.transitionDelay = `${ + (index * staggerDelay) / 1000 + }s`; + }); + element.classList.add("in-view"); + observer.unobserve(element); + } + }, + { threshold, rootMargin: "-50px" }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [staggerDelay, threshold]); + + return ( +
    + {children} +
    + ); +} diff --git a/src/components/ui/SectionHeading.tsx b/src/components/ui/SectionHeading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd2f031506ac8452016cb0f4b805ba3060bc50b2 --- /dev/null +++ b/src/components/ui/SectionHeading.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export interface SectionHeadingProps { + title: string; + /** small mono kicker above the title, e.g. "01 · LEADERBOARD" */ + eyebrow?: string; + /** muted one-liner under the title — accepts JSX so data numerals + can be typeset in `.num` mono */ + sub?: ReactNode; + /** heading element — pass "h1" for the route's primary heading */ + as?: "h1" | "h2"; + className?: string; +} + +/** Editorial section header: mono eyebrow, Instrument Serif title, hairline rule. */ +export default function SectionHeading({ + title, + eyebrow, + sub, + as = "h2", + className, +}: SectionHeadingProps) { + const Tag = as; + return ( +
    + {eyebrow && ( +

    + {eyebrow} +

    + )} + + {title} + + {sub && ( +

    + {sub} +

    + )} +
    + ); +} diff --git a/src/components/ui/SplitToggle.tsx b/src/components/ui/SplitToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29f878f7373f5901cb518a49f23cf22c6e081691 --- /dev/null +++ b/src/components/ui/SplitToggle.tsx @@ -0,0 +1,48 @@ +"use client"; + +import clsx from "clsx"; +import type { Split } from "@/lib/types"; +import { splitLabels } from "@/lib/data"; + +export interface SplitToggleProps { + value: Split; + onChange: (split: Split) => void; + className?: string; +} + +const SPLITS: Split[] = ["test", "hard"]; + +/** Controlled Test/Hard segmented control. */ +export default function SplitToggle({ + value, + onChange, + className, +}: SplitToggleProps) { + return ( +
    + {SPLITS.map((s) => ( + + ))} +
    + ); +} diff --git a/src/data/results.json b/src/data/results.json new file mode 100644 index 0000000000000000000000000000000000000000..44a41b9d82f9ba6cfdda6347b52f51e2d4cabd73 --- /dev/null +++ b/src/data/results.json @@ -0,0 +1,459 @@ +{ + "datasets": [ + { + "info": { + "id": "vimedcss", + "name": "ViMedCSS", + "status": "live", + "fullName": "Vietnamese Medical Code-Switching Speech", + "tagline": "Code-switched medical speech from Vietnamese lectures — every utterance carries at least one English clinical term.", + "source": "Vietnamese medical YouTube lectures", + "hours": 34.57, + "utterances": 16576, + "csTerms": 889, + "license": "CC-BY-4.0", + "arxiv": "2602.12911", + "hf": "tensorxt/ViMedCSS", + "splits": { + "train": { + "hours": 24.31, + "utts": 11833 + }, + "valid": { + "hours": 3.56, + "utts": 1714 + }, + "test": { + "hours": 3.38, + "utts": 1615 + }, + "hard": { + "hours": 1.38, + "utts": 658 + } + } + }, + "models": [ + { + "slug": "phowhisper-small-ag", + "name": "PhoWhisper-small + Attention-Guide", + "org": "ViMedCSS paper", + "paramsB": 0.244, + "license": "BSD-3-Clause", + "category": "vn-open", + "track": "C", + "provenance": "paper-imported", + "flags": { + "frozen": true, + "zone1": true + }, + "note": "Published adapted SOTA", + "metrics": { + "test": { + "wer": 23.67, + "csWer": 19.5 + } + } + }, + { + "slug": "phowhisper-small-lora", + "name": "PhoWhisper-small + LoRA", + "org": "ViMedCSS paper", + "paramsB": 0.244, + "license": "BSD-3-Clause", + "category": "vn-open", + "track": "C", + "provenance": "paper-imported", + "flags": { + "frozen": true, + "zone1": true + }, + "metrics": { + "test": { + "wer": 27.13, + "csWer": 30.26 + } + } + }, + { + "slug": "vietasr", + "name": "VietASR (Zipformer 68M)", + "org": "SJTU / zzasdf", + "paramsB": 0.068, + "license": "Apache-2.0", + "category": "vn-open", + "track": "A", + "provenance": "paper-imported", + "flags": { + "zone1": true + }, + "note": "Best published zero-shot", + "metrics": { + "test": { + "wer": 27.56, + "csWer": 58.38 + } + } + }, + { + "slug": "phowhisper-large", + "name": "PhoWhisper-large", + "org": "VinAI", + "paramsB": 1.55, + "license": "BSD-3-Clause", + "category": "vn-open", + "track": "A", + "provenance": "paper-imported", + "flags": { + "frozen": true, + "zone1": true + }, + "note": "Clinical Scribe incumbent candidate", + "metrics": { + "test": { + "wer": 31.24, + "csWer": 55.05 + } + } + }, + { + "slug": "whisper-large-v3", + "name": "Whisper-large-v3", + "org": "OpenAI", + "paramsB": 1.55, + "license": "Apache-2.0", + "category": "multilingual-open", + "track": "A", + "provenance": "paper-imported", + "flags": { + "zone1": true + }, + "note": "Best published zero-shot CS-WER", + "metrics": { + "test": { + "wer": 34.47, + "csWer": 46.69 + } + } + }, + { + "slug": "phowhisper-small", + "name": "PhoWhisper-small", + "org": "VinAI", + "paramsB": 0.244, + "license": "BSD-3-Clause", + "category": "vn-open", + "track": "A", + "provenance": "paper-imported", + "flags": { + "frozen": true, + "zone1": true + }, + "metrics": { + "test": { + "wer": 36.31 + } + } + }, + { + "slug": "wav2vec2-base-vi", + "name": "wav2vec2-base-vi", + "org": "—", + "paramsB": 0.095, + "license": "Open", + "category": "vn-open", + "track": "A", + "provenance": "paper-imported", + "metrics": { + "test": { + "wer": 44.74 + } + } + }, + { + "slug": "whisper-small", + "name": "Whisper-small", + "org": "OpenAI", + "paramsB": 0.244, + "license": "Apache-2.0", + "category": "multilingual-open", + "track": "A", + "provenance": "paper-imported", + "metrics": { + "test": { + "wer": 50.03 + } + } + }, + { + "slug": "mms-1b", + "name": "MMS-1B", + "org": "Meta", + "paramsB": 1, + "license": "CC-BY-NC-4.0", + "category": "multilingual-open", + "track": "A", + "provenance": "paper-imported", + "metrics": { + "test": { + "wer": 58.3 + } + } + }, + { + "slug": "zipformer-30m", + "name": "Zipformer-30M RNNT (VLSP 2025)", + "org": "hynt", + "paramsB": 0.03, + "license": "CC-BY-NC-ND-4.0", + "category": "vn-open", + "track": "A", + "provenance": "platform-verified", + "flags": { + "referenceOnly": true + }, + "note": "VLSP 2025 challenge winner, run as the platform's reference yardstick", + "metrics": { + "test": { + "wer": 19.81, + "werCi": [ + 19.06, + 20.53 + ], + "cer": 15.92, + "csWer": 74.55, + "csWerCi": [ + 72.35, + 76.89 + ], + "nWer": 17.78, + "mtrExact": 27.31, + "mtrFuzzy": 36.48, + "rtfx": 48.12 + }, + "hard": { + "wer": 25.31, + "werCi": [ + 23.79, + 26.9 + ], + "cer": 20.01, + "csWer": 80.86, + "csWerCi": [ + 77.48, + 84.09 + ], + "nWer": 23.02, + "mtrExact": 19.81, + "mtrFuzzy": 27.7, + "rtfx": 54.72 + } + } + }, + { + "slug": "qwen3-asr-0.6b", + "name": "Qwen3-ASR-0.6B", + "org": "Alibaba", + "paramsB": 0.6, + "license": "Apache-2.0", + "category": "multilingual-open", + "track": "A", + "provenance": "platform-verified", + "flags": { + "zone1": true + }, + "note": "First sub-1B audio-LLM on the board — strongest code-switching recall of the verified runs", + "metrics": { + "test": { + "wer": 21.29, + "werCi": [ + 20.5, + 22.02 + ], + "cer": 16.11, + "csWer": 68.49, + "csWerCi": [ + 66.12, + 71.03 + ], + "nWer": 19.54, + "mtrExact": 32.29, + "mtrFuzzy": 42.31, + "rtfx": 4.96 + }, + "hard": { + "wer": 25, + "werCi": [ + 23.47, + 26.4 + ], + "cer": 18.63, + "csWer": 68.3, + "csWerCi": [ + 64.51, + 72.18 + ], + "nWer": 23.22, + "mtrExact": 30.76, + "mtrFuzzy": 38, + "rtfx": 8.82 + } + } + }, + { + "slug": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro (audio)", + "org": "Google", + "license": "API", + "category": "api-intl", + "track": "A", + "provenance": "planned", + "flags": { + "contamination": true + }, + "note": "Seeded this dataset labels — permanently contamination-flagged" + }, + { + "slug": "gemini-3.5-flash", + "name": "Gemini 3.5 Flash (audio)", + "org": "Google", + "license": "API", + "category": "api-intl", + "track": "A", + "provenance": "api-snapshot", + "flags": { + "contamination": true + }, + "note": "Gemini-family — ViMedCSS labels were Gemini-2.5-seeded, so every Gemini result carries a permanent contamination flag", + "metrics": { + "test": { + "wer": 13.84, + "werCi": [ + 13.06, + 14.72 + ], + "cer": 12.22, + "csWer": 29.38, + "csWerCi": [ + 26.95, + 31.89 + ], + "nWer": 13.26, + "mtrExact": 71.25, + "mtrFuzzy": 80.75 + }, + "hard": { + "wer": 16.08, + "werCi": [ + 14.46, + 17.85 + ], + "cer": 14.12, + "csWer": 24.66, + "csWerCi": [ + 21.25, + 28.24 + ], + "nWer": 15.72, + "mtrExact": 75.68, + "mtrFuzzy": 80.03 + } + } + }, + { + "slug": "deepgram-nova-3", + "name": "Deepgram Nova-3", + "org": "Deepgram", + "license": "API", + "category": "api-intl", + "track": "A", + "provenance": "api-snapshot", + "note": "Latest Deepgram model with Vietnamese support; date-stamped API snapshot", + "metrics": { + "test": { + "wer": 31.53, + "werCi": [ + 30, + 33.14 + ], + "cer": 28.45, + "csWer": 70.75, + "csWerCi": [ + 68.34, + 73.47 + ], + "nWer": 30.07, + "mtrExact": 29.99, + "mtrFuzzy": 38.83 + }, + "hard": { + "wer": 37.68, + "werCi": [ + 35.04, + 40.26 + ], + "cer": 34.03, + "csWer": 76.88, + "csWerCi": [ + 73.07, + 80.31 + ], + "nWer": 36.06, + "mtrExact": 23.67, + "mtrFuzzy": 33.17 + } + } + }, + { + "slug": "gpt-4o-transcribe", + "name": "gpt-4o-transcribe", + "org": "OpenAI", + "license": "API", + "category": "api-intl", + "track": "A", + "provenance": "planned" + }, + { + "slug": "elevenlabs-scribe-v2", + "name": "ElevenLabs Scribe v2", + "org": "ElevenLabs", + "license": "API", + "category": "api-intl", + "track": "B", + "provenance": "planned", + "note": "Keyterm biasing — Track B" + }, + { + "slug": "fpt-ai-stt", + "name": "FPT.AI STT", + "org": "FPT Smart Cloud", + "license": "API", + "category": "api-vn", + "track": "A", + "provenance": "planned" + } + ] + }, + { + "info": { + "id": "vietmed", + "name": "VietMed", + "status": "coming-soon", + "tagline": "Real-world Vietnamese medical speech recorded in actual doctor–patient conversations.", + "source": "Real-world medical speech recordings" + }, + "models": [] + }, + { + "info": { + "id": "private-clinic", + "name": "Private Clinic Set", + "status": "coming-soon", + "tagline": "Private conversational recordings from partner clinics, held out from public release to resist test-set contamination.", + "source": "Partner clinic consultations", + "eta": "2026" + }, + "models": [] + } + ] +} diff --git a/src/lib/data.ts b/src/lib/data.ts new file mode 100644 index 0000000000000000000000000000000000000000..96c6e3c08df599e8b961d7a7e1b4818e1430ccd5 --- /dev/null +++ b/src/lib/data.ts @@ -0,0 +1,159 @@ +import resultsJson from "@/data/results.json"; +import type { + Category, + DatasetEntry, + DatasetInfo, + MetricKey, + ModelRow, + Provenance, + ResultsFile, + Split, + Track, +} from "./types"; + +/* JSON tuples ([lo, hi] CIs) widen to number[] under resolveJsonModule, + so we narrow through unknown once, here, and nowhere else. */ +const results = resultsJson as unknown as ResultsFile; + +/* ------------------------------------------------------------------ */ +/* Platform version strings — single source of truth. */ +/* Every surface (run manifests, methodology exemplar, nav badge) */ +/* must reference these, never a literal. */ +/* ------------------------------------------------------------------ */ + +export const NORMALIZER_VERSION = "v0.1"; +export const HARNESS_VERSION = "medasr-bench v0.4"; + +/* ------------------------------------------------------------------ */ +/* Dataset registry */ +/* ------------------------------------------------------------------ */ + +/** The anchor (first live) dataset — what no-arg accessors resolve to. */ +export const ANCHOR_DATASET_ID = "vimedcss"; + +/** All dataset profiles, in registry order. */ +export function getDatasets(): DatasetInfo[] { + return results.datasets.map((d) => d.info); +} + +/** Only datasets with leaderboards running today. */ +export function getLiveDatasets(): DatasetInfo[] { + return results.datasets + .filter((d) => d.info.status === "live") + .map((d) => d.info); +} + +/** Full registry entry (profile + model rows). No-arg returns the anchor. */ +export function getDatasetEntry(id: string = ANCHOR_DATASET_ID): DatasetEntry { + const entry = results.datasets.find((d) => d.info.id === id); + if (!entry) { + const known = results.datasets.map((d) => d.info.id).join(", "); + throw new Error( + `Unknown dataset id "${id}". Registered datasets: ${known}.`, + ); + } + return entry; +} + +/** Dataset profile by id. No-arg returns the anchor (ViMedCSS). */ +export function getDataset(id: string = ANCHOR_DATASET_ID): DatasetInfo { + return getDatasetEntry(id).info; +} + +/** All model rows for a dataset, in seed order. No-arg: anchor dataset. */ +export function getModels(datasetId: string = ANCHOR_DATASET_ID): ModelRow[] { + return getDatasetEntry(datasetId).models; +} + +/** Single model by slug within a dataset, or undefined. */ +export function getModel( + slug: string, + datasetId: string = ANCHOR_DATASET_ID, +): ModelRow | undefined { + return getDatasetEntry(datasetId).models.find((m) => m.slug === slug); +} + +/** Metrics where a higher value is better (everything else: lower wins). */ +export const HIGHER_IS_BETTER: ReadonlySet = new Set([ + "mtrExact", + "mtrFuzzy", + "rtfx", +]); + +/** + * Models that have `metric` measured on `split`, sorted best-first + * (ascending for WER-family, descending for MTR/RTFx). + * Rows without the metric (running/planned) are excluded. + */ +export function getRanked( + split: Split, + metric: MetricKey = "wer", + datasetId: string = ANCHOR_DATASET_ID, +): ModelRow[] { + const dir = HIGHER_IS_BETTER.has(metric) ? -1 : 1; + return getDatasetEntry(datasetId) + .models.filter((m) => typeof m.metrics?.[split]?.[metric] === "number") + .sort( + (a, b) => + dir * ((a.metrics![split]![metric] as number) - + (b.metrics![split]![metric] as number)), + ); +} + +/* ------------------------------------------------------------------ */ +/* Label maps */ +/* ------------------------------------------------------------------ */ + +export const categoryLabels: Record = { + "vn-open": "Vietnamese open", + "multilingual-open": "Multilingual open", + medical: "Medical-specialized", + "api-intl": "Commercial API (intl)", + "api-vn": "Commercial API (VN)", +}; + +export const trackLabels: Record = { + A: "Track A — Zero-shot", + B: "Track B — Contextual biasing", + C: "Track C — Adapted", +}; + +export const provenanceLabels: Record = { + "platform-verified": "Platform-verified", + "api-snapshot": "API snapshot", + "self-reported": "Self-reported", + "paper-imported": "Paper-imported", + running: "Running", + planned: "Planned", +}; + +export const metricLabels: Record = { + wer: "WER", + cer: "CER", + csWer: "CS-WER", + nWer: "N-WER", + mtrExact: "MTR (exact)", + mtrFuzzy: "MTR (fuzzy)", + rtfx: "RTFx", +}; + +export const splitLabels: Record = { + test: "Test", + hard: "Hard", +}; + +/* ------------------------------------------------------------------ */ +/* Formatters */ +/* ------------------------------------------------------------------ */ + +/** Fixed-point number, or em-dash when missing. fmt(23.5) → "23.50". */ +export function fmt(x: number | null | undefined, digits = 2): string { + if (x === null || x === undefined || Number.isNaN(x)) return "—"; + return x.toFixed(digits); +} + +/** 95% CI as "lo–hi" (en dash), or "" when missing. fmtCI([22.8, 24.5]) → "22.80–24.50". */ +export function fmtCI(ci: [number, number] | undefined, digits = 2): string { + if (!ci) return ""; + return `${ci[0].toFixed(digits)}–${ci[1].toFixed(digits)}`; +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..df628e65f6743e4accb3677430e59c0c0802a478 --- /dev/null +++ b/src/lib/hooks.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * useScrollReveal - Hook to detect when elements come into view + * Adds/removes 'in-view' class for CSS scroll animations + */ +export function useScrollReveal( + threshold = 0.1, + rootMargin = "-50px" +) { + const ref = useRef(null); + const [isInView, setIsInView] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + observer.unobserve(element); + } else { + setIsInView(false); + } + }, + { + threshold, + rootMargin, + } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [threshold, rootMargin]); + + return { ref, isInView }; +} + +/** + * useStaggerReveal - Hook for staggered child animations + */ +export function useStaggerReveal(count: number) { + const ref = useRef(null); + const [visibleCount, setVisibleCount] = useState(0); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + // Animate children in with stagger + let current = 0; + const interval = setInterval(() => { + current++; + setVisibleCount(current); + if (current >= count) { + clearInterval(interval); + observer.unobserve(element); + } + }, 60); + + return () => clearInterval(interval); + } + }, + { threshold: 0.1, rootMargin: "-50px" } + ); + + observer.observe(element); + + return () => observer.disconnect(); + }, [count]); + + return { ref, visibleCount }; +} + +/** + * useParallax - Hook for parallax scroll effects + */ +export function useParallax(speed: number = 0.5) { + const [offset, setOffset] = useState(0); + + useEffect(() => { + let ticking = false; + + const handleScroll = () => { + if (!ticking) { + requestAnimationFrame(() => { + setOffset(window.scrollY * speed); + ticking = false; + }); + ticking = true; + } + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [speed]); + + return offset; +} + +/** + * useReducedMotion - Hook to check user's motion preference + */ +export function useReducedMotion(): boolean { + const [prefersReduced, setPrefersReduced] = useState(() => { + // Check synchronously during render to avoid flash + if (typeof window !== "undefined") { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + return false; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mediaQuery.addEventListener("change", handler); + + return () => mediaQuery.removeEventListener("change", handler); + }, []); + + return prefersReduced; +} + +/** + * useMousePosition - Hook to track mouse position + */ +export function useMousePosition() { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + setPosition({ x: e.clientX, y: e.clientY }); + }; + + window.addEventListener("mousemove", handleMouseMove); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + return position; +} diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9fb9d12bd25f55e2dd6bc335f55d66e8db685f4 --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,202 @@ +"use client"; + +import type { Transition, Variants } from "framer-motion"; + +/** + * Spring-based easing functions matching the CSS tokens + */ +export const springTransition: Transition = { + type: "spring", + stiffness: 300, + damping: 30, +}; + +export const gentleSpring: Transition = { + type: "spring", + stiffness: 200, + damping: 25, +}; + +export const bouncySpring: Transition = { + type: "spring", + stiffness: 400, + damping: 20, +}; + +/** + * Animation variants for entrance animations + */ +export const fadeIn: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] }, + }, +}; + +export const fadeInUp: Variants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +export const fadeInScale: Variants = { + hidden: { opacity: 0, scale: 0.95, y: 12 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { duration: 0.5, ease: [0.34, 1.56, 0.64, 1] }, + }, +}; + +export const slideInLeft: Variants = { + hidden: { opacity: 0, x: -20 }, + visible: { + opacity: 1, + x: 0, + transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +export const slideInRight: Variants = { + hidden: { opacity: 0, x: 20 }, + visible: { + opacity: 1, + x: 0, + transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +/** + * Stagger container variants + */ +export const staggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.06, + delayChildren: 0.1, + }, + }, +}; + +export const staggerContainerFast: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.04, + delayChildren: 0.05, + }, + }, +}; + +/** + * List item variants for staggered animations + */ +export const listItem: Variants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: springTransition, + }, +}; + +export const listItemSpring: Variants = { + hidden: { opacity: 0, scale: 0.95, y: 12 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { duration: 0.5, ease: [0.34, 1.56, 0.64, 1] }, + }, +}; + +/** + * Page transition variants + */ +export const pageTransition: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] }, + }, + exit: { + opacity: 0, + y: -8, + transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +/** + * Hover scale variant for interactive elements + */ +export const hoverScale: Variants = { + rest: { scale: 1 }, + hover: { + scale: 1.02, + transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }, + }, + tap: { + scale: 0.98, + transition: { duration: 0.1 }, + }, +}; + +/** + * Glow pulse variant for accent elements + */ +export const glowPulse: Variants = { + initial: { opacity: 0.6 }, + animate: { + opacity: [0.6, 1, 0.6], + transition: { + duration: 2, + repeat: Infinity, + ease: "easeInOut", + }, + }, +}; + +/** + * Draw line variant for SVG paths + */ +export const drawLine: Variants = { + hidden: { pathLength: 0, opacity: 0 }, + visible: { + pathLength: 1, + opacity: 1, + transition: { duration: 1.2, ease: [0.4, 0, 0.2, 1] }, + }, +}; + +/** + * Ambient float animation + */ +export const float: Variants = { + initial: { y: 0 }, + animate: { + y: [-4, 4, -4], + transition: { + duration: 6, + repeat: Infinity, + ease: "easeInOut", + }, + }, +}; + +/** + * Reduced motion check helper + * Returns true if user prefers reduced motion + */ +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6f6cfaa6965a72383b9591628410b5282863c04 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,108 @@ +/** Metrics for one evaluation split (test or hard). All values optional — + * absent means "not yet measured". WER-family values are percentages. */ +export interface SplitMetrics { + wer?: number; + werCi?: [number, number]; + cer?: number; + csWer?: number; + csWerCi?: [number, number]; + nWer?: number; + mtrExact?: number; + mtrFuzzy?: number; + rtfx?: number; +} + +/** One leaderboard row. */ +export interface ModelRow { + slug: string; + name: string; + org: string; + paramsB?: number; + license: string; + category: "vn-open" | "multilingual-open" | "medical" | "api-intl" | "api-vn"; + track: "A" | "B" | "C"; + provenance: + | "platform-verified" + | "api-snapshot" + | "self-reported" + | "paper-imported" + | "running" + | "planned"; + flags?: { + contamination?: boolean; + referenceOnly?: boolean; + frozen?: boolean; + zone1?: boolean; + }; + note?: string; + metrics?: { test?: SplitMetrics; hard?: SplitMetrics }; +} + +/* Convenience aliases derived from ModelRow */ +export type Category = ModelRow["category"]; +export type Track = ModelRow["track"]; +export type Provenance = ModelRow["provenance"]; +export type ModelFlags = NonNullable; + +/** The two leaderboard splits. */ +export type Split = "test" | "hard"; + +/** Rankable metric keys within SplitMetrics (CI tuples excluded). */ +export type MetricKey = + | "wer" + | "cer" + | "csWer" + | "nWer" + | "mtrExact" + | "mtrFuzzy" + | "rtfx"; + +export interface SplitInfo { + hours: number; + utts: number; +} + +/** Lifecycle of a dataset on the hub. */ +export type DatasetStatus = "live" | "coming-soon"; + +/** + * Dataset profile. MedASR Bench is a multi-dataset hub: live datasets + * carry full statistics; coming-soon entries only carry identity fields, + * so everything measured is optional. + */ +export interface DatasetInfo { + id: string; + name: string; + status: DatasetStatus; + /** Expanded name, e.g. "Vietnamese Medical Code-Switching Speech". */ + fullName?: string; + /** One-sentence description for dataset cards. */ + tagline: string; + /** Where the audio comes from, e.g. "Vietnamese medical YouTube lectures". */ + source?: string; + /** Expected availability for coming-soon datasets, e.g. "2026". */ + eta?: string; + hours?: number; + utterances?: number; + csTerms?: number; + license?: string; + arxiv?: string; + hf?: string; + splits?: { + train: SplitInfo; + valid: SplitInfo; + test: SplitInfo; + hard: SplitInfo; + }; +} + +/** One registry entry: a dataset profile plus its leaderboard rows. */ +export interface DatasetEntry { + info: DatasetInfo; + models: ModelRow[]; +} + +/** Shape of src/data/results.json. */ +export interface ResultsFile { + datasets: DatasetEntry[]; +}