diff --git a/ui/src/__tests__/components/common/status-badge.test.tsx b/ui/src/__tests__/components/common/status-badge.test.tsx index 3a79d837..6c293efd 100644 --- a/ui/src/__tests__/components/common/status-badge.test.tsx +++ b/ui/src/__tests__/components/common/status-badge.test.tsx @@ -48,9 +48,33 @@ describe('StatusBadge', () => { expect(badge!.getAttribute('data-value')).toBe(c.value); }); - it('renders the value as the visible label', () => { + it('humanizes a single-word wire value for the visible label', () => { render(); - expect(screen.getByText('running')).toBeInTheDocument(); + // Label is humanized (Title case); the raw wire value stays in data-value. + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.queryByText('running')).toBeNull(); + }); + + it.each([ + { kind: 'proposal' as const, value: 'pr_opened', label: 'PR opened' }, + { kind: 'proposal' as const, value: 'pr_merged', label: 'PR merged' }, + { kind: 'proposal' as const, value: 'superseded', label: 'Superseded' }, + { kind: 'judgment_source' as const, value: 'llm', label: 'LLM-as-judge' }, + { kind: 'judgment_source' as const, value: 'human', label: 'Human' }, + { kind: 'judgment_source' as const, value: 'click', label: 'Click (UBI)' }, + ])('renders explicit display label for kind=$kind value=$value', ({ kind, value, label }) => { + render(); + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.queryByText(value)).toBeNull(); + }); + + it('judgment_source values get a real (non-secondary) variant, not the fallback', () => { + // Regression: the source column previously used kind="judgment_list", whose + // table has no llm/human/click keys, so every source fell through to the + // secondary variant. judgment_source now maps them explicitly. + const { container } = render(); + const badge = container.querySelector('span[data-kind][data-value]'); + expect(badge!.className).toContain('bg-blue-100'); // 'default' variant, not secondary }); it('falls back to secondary variant for unknown (kind, value)', () => { diff --git a/ui/src/__tests__/lib/labels.test.ts b/ui/src/__tests__/lib/labels.test.ts new file mode 100644 index 00000000..4ae61567 --- /dev/null +++ b/ui/src/__tests__/lib/labels.test.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; + +import { + JUDGMENT_SOURCE_VALUES, + OBJECTIVE_METRIC_VALUES, + PRUNER_VALUES, + SAMPLER_VALUES, +} from '@/lib/enums'; +import { + formatMetricLabel, + humanizeWireValue, + JUDGMENT_SOURCE_LABELS, + METRIC_LABELS, + PRUNER_LABELS, + SAMPLER_LABELS, +} from '@/lib/labels'; + +describe('humanizeWireValue', () => { + it('turns snake_case into Title case', () => { + expect(humanizeWireValue('still_improving')).toBe('Still improving'); + expect(humanizeWireValue('running')).toBe('Running'); + expect(humanizeWireValue('too_few_trials')).toBe('Too few trials'); + }); +}); + +describe('formatMetricLabel', () => { + it('renders NDCG@10 with the acronym uppercased and cutoff appended', () => { + expect(formatMetricLabel('ndcg', 10)).toBe('NDCG@10'); + expect(formatMetricLabel('map', 5)).toBe('MAP@5'); + }); + it('omits @k for cutoff-less metrics (k null)', () => { + expect(formatMetricLabel('mrr', null)).toBe('MRR'); + }); + it('falls back to uppercase for an unknown metric', () => { + expect(formatMetricLabel('foo', 3)).toBe('FOO@3'); + }); +}); + +describe('label maps cover every enum value (no raw value can leak)', () => { + it('METRIC_LABELS covers OBJECTIVE_METRIC_VALUES', () => { + for (const m of OBJECTIVE_METRIC_VALUES) expect(METRIC_LABELS[m]).toBeTruthy(); + }); + it('SAMPLER_LABELS covers SAMPLER_VALUES', () => { + for (const s of SAMPLER_VALUES) expect(SAMPLER_LABELS[s]).toBeTruthy(); + }); + it('PRUNER_LABELS covers PRUNER_VALUES', () => { + for (const p of PRUNER_VALUES) expect(PRUNER_LABELS[p]).toBeTruthy(); + }); + it('JUDGMENT_SOURCE_LABELS covers JUDGMENT_SOURCE_VALUES', () => { + for (const s of JUDGMENT_SOURCE_VALUES) expect(JUDGMENT_SOURCE_LABELS[s]).toBeTruthy(); + }); +}); diff --git a/ui/src/components/common/status-badge.tsx b/ui/src/components/common/status-badge.tsx index c4c916c8..631338e0 100644 --- a/ui/src/components/common/status-badge.tsx +++ b/ui/src/components/common/status-badge.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Badge, type BadgeProps } from '@/components/ui/badge'; +import { humanizeWireValue, JUDGMENT_SOURCE_LABELS } from '@/lib/labels'; type BadgeVariant = NonNullable; @@ -40,6 +41,13 @@ const VARIANT_TABLE: Record> = { complete: 'success', failed: 'destructive', }, + // Judgment-list SOURCE (how ratings were produced), distinct from the + // list's generation status above. Values: backend JudgmentSource. + judgment_source: { + llm: 'default', + human: 'success', + click: 'secondary', + }, health: { green: 'success', yellow: 'warning', @@ -48,6 +56,19 @@ const VARIANT_TABLE: Record> = { }, }; +// Explicit display labels where the humanizer fallback would be wrong (acronym +// casing). Anything not listed falls back to `humanizeWireValue`. Keyed the +// same (kind, value) as VARIANT_TABLE. +const LABEL_TABLE: Record> = { + proposal: { + pr_opened: 'PR opened', + pr_merged: 'PR merged', + }, + // proposal_pr (open/closed/merged) intentionally omitted — humanizeWireValue + // already yields Open/Closed/Merged, so an explicit map would be redundant. + judgment_source: JUDGMENT_SOURCE_LABELS, +}; + export type StatusBadgeKind = keyof typeof VARIANT_TABLE; export interface StatusBadgeProps { @@ -58,13 +79,18 @@ export interface StatusBadgeProps { export function StatusBadge({ kind, value, className }: StatusBadgeProps) { // `kind` is typed as StatusBadgeKind (typescript-enforced); `value` is a wire string - // we explicitly chain-default to 'secondary' on miss. The eslint security plugin - // can't see the TypeScript type narrowing — suppress with cited safety argument. + // we explicitly chain-default on miss. The eslint security plugin can't see the + // TypeScript type narrowing — suppress with cited safety argument. // eslint-disable-next-line security/detect-object-injection const variant = VARIANT_TABLE[kind]?.[value] ?? 'secondary'; + // Prefer an explicit display label; fall back to humanizing the wire value + // (snake_case → Title case) so raw values like `pr_merged` / `still_improving` + // never reach the user. + // eslint-disable-next-line security/detect-object-injection + const label = LABEL_TABLE[kind]?.[value] ?? humanizeWireValue(value); return ( - {value} + {label} ); } diff --git a/ui/src/components/judgments/judgments-table.column-config.tsx b/ui/src/components/judgments/judgments-table.column-config.tsx index 70385654..a6477129 100644 --- a/ui/src/components/judgments/judgments-table.column-config.tsx +++ b/ui/src/components/judgments/judgments-table.column-config.tsx @@ -63,7 +63,7 @@ export function useJudgmentsColumns(listId: string): DataTableColumnDef ( - + ), }, diff --git a/ui/src/components/judgments/judgments-table.tsx b/ui/src/components/judgments/judgments-table.tsx index c6d4600d..2498d688 100644 --- a/ui/src/components/judgments/judgments-table.tsx +++ b/ui/src/components/judgments/judgments-table.tsx @@ -64,7 +64,7 @@ export function JudgmentsTable({ anyMatcherActive={urlState.anyMatcherActive} emptyStateNoRows={{ title: 'No judgments yet', - message: 'Generate judgments via the calibration modal.', + message: 'Generate a judgment list from a query set (LLM-as-judge) or from UBI click data.', }} emptyStateNoMatch={{ title: 'No judgments match', diff --git a/ui/src/components/studies/confidence-panel.tsx b/ui/src/components/studies/confidence-panel.tsx index 888cac1a..f95af058 100644 --- a/ui/src/components/studies/confidence-panel.tsx +++ b/ui/src/components/studies/confidence-panel.tsx @@ -7,6 +7,7 @@ import { InfoTooltip } from '@/components/common/info-tooltip'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatMetricLabel } from '@/lib/labels'; import { Table, TableBody, @@ -36,10 +37,6 @@ const CONVERGENCE_BADGE: Record {OBJECTIVE_METRIC_VALUES.map((m) => ( - {m} + {METRIC_LABELS[m]} ))} @@ -1288,7 +1289,7 @@ export function CreateStudyModal({ open, onOpenChange, initialValues }: CreateSt className="text-sm text-muted-foreground" data-testid="cs-k-ignored-caption" > - {metric.toUpperCase()} evaluates the full ranked list — no cutoff used. + {METRIC_LABELS[metric]} evaluates the full ranked list — no cutoff used.

); } @@ -1469,7 +1470,7 @@ export function CreateStudyModal({ open, onOpenChange, initialValues }: CreateSt {SAMPLER_VALUES.map((s) => ( - {s} + {SAMPLER_LABELS[s]} ))} @@ -1490,7 +1491,7 @@ export function CreateStudyModal({ open, onOpenChange, initialValues }: CreateSt {PRUNER_VALUES.map((p) => ( - {p} + {PRUNER_LABELS[p]} ))} diff --git a/ui/src/lib/labels.ts b/ui/src/lib/labels.ts new file mode 100644 index 00000000..ecfe4edc --- /dev/null +++ b/ui/src/lib/labels.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 soundminds.ai +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Human-readable labels for backend wire values. + * + * The backend emits snake_case / lowercase enum values (`pr_merged`, `ndcg`, + * `tpe`). Those are correct on the wire but must never reach the user verbatim. + * This module is the single source of display labels, so the same concept reads + * the same way everywhere (e.g. `NDCG@10`, not `ndcg` on one screen and + * `NDCG@10` on another). Wire values still come from `@/lib/enums` — these maps + * only decorate them for display; they never widen the type or bypass the + * enum-source-of-truth policy. + */ + +import type { JudgmentSource, ObjectiveMetric, PrunerKind, SamplerKind } from '@/lib/enums'; + +/** Fallback humanizer: `still_improving` → `Still improving`. */ +export function humanizeWireValue(value: string): string { + const spaced = value.replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** Objective-metric acronyms (uppercase; `@k` appended by formatMetricLabel). */ +export const METRIC_LABELS: Record = { + ndcg: 'NDCG', + map: 'MAP', + precision: 'Precision', + recall: 'Recall', + mrr: 'MRR', +}; + +/** + * Canonical rendering of a metric with its cutoff, used everywhere a metric is + * shown. MRR ignores `k` (evaluates the full ranked list), so `@k` is omitted + * when `k` is null. + */ +export function formatMetricLabel(metric: string, k: number | null): string { + const base = METRIC_LABELS[metric as ObjectiveMetric] ?? metric.toUpperCase(); + return k != null ? `${base}@${k}` : base; +} + +export const SAMPLER_LABELS: Record = { + tpe: 'TPE (learns from prior trials)', + random: 'Random', +}; + +export const PRUNER_LABELS: Record = { + median: 'Median (early-stop weak trials)', + none: 'None', +}; + +export const JUDGMENT_SOURCE_LABELS: Record = { + llm: 'LLM-as-judge', + human: 'Human', + click: 'Click (UBI)', +};