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)',
+};