diff --git a/web/packages/common/src/hooks/useStudioDataViewState/filterFieldMap.integration.test.tsx b/web/packages/common/src/hooks/useStudioDataViewState/filterFieldMap.integration.test.tsx index 4f80bc4edc..97d7414e9a 100644 --- a/web/packages/common/src/hooks/useStudioDataViewState/filterFieldMap.integration.test.tsx +++ b/web/packages/common/src/hooks/useStudioDataViewState/filterFieldMap.integration.test.tsx @@ -122,3 +122,121 @@ describe('useStudioDataViewState range-filter integration', () => { ); }); }); + +/** Evaluator columns use dynamic `evaluator-` ids, so the view passes a function-form + * `filterFieldMap` deriving `evaluators..mean`. Guards that path end-to-end. */ +interface EvaluatorRow { + aggregate_scores?: { [name: string]: { mean?: number } }; +} + +const EVALUATOR_DATA: EvaluatorRow[] = [ + { aggregate_scores: { accuracy: { mean: 0.2 } } }, + { aggregate_scores: { accuracy: { mean: 0.6 } } }, + { aggregate_scores: { accuracy: { mean: 0.95 } } }, +]; + +// Mirrors ExperimentGroupDataView's getExperimentFilterField for the dynamic evaluator id. +const evaluatorFilterField = (id: string): string | undefined => { + const match = id.match(/^evaluator-(.+)$/); + return match ? `evaluators.${match[1]}.mean` : undefined; +}; + +function useEvaluatorHarness() { + const dataViewState = useStudioDataViewState({ filterFieldMap: evaluatorFilterField }); + const columns = useMakeColumns({ + makeColumns: (columnHelper) => [ + columnHelper.accessor((r) => r.aggregate_scores?.accuracy?.mean, { + id: 'evaluator-accuracy', + header: 'Avg Accuracy', + meta: { filter: numberRangeFilter('Avg Accuracy') }, + }), + ], + overrideToLoadingCells: false, + }); + const table = useCustomReactTable({ + columns, + data: EVALUATOR_DATA, + dataMode: 'manual', + state: dataViewState, + totalCount: EVALUATOR_DATA.length, + }); + return { dataViewState, table }; +} + +describe('useStudioDataViewState range-filter integration (function-form filterFieldMap)', () => { + it('remaps a dynamic evaluator column id to its dotted API key', async () => { + const { result } = renderHook(() => useEvaluatorHarness(), { wrapper: MemoryRouter }); + + // Same regression guard as latency: the column must resolve to the numberRange filterFn. + expect(result.current.table.getColumn('evaluator-accuracy')?.columnDef.filterFn).toBe( + 'numberRange' + ); + + act(() => { + result.current.table + .getColumn('evaluator-accuracy') + ?.setFilterValue({ $gte: 0.5, $lte: 0.9 }); + }); + + await waitFor( + () => + expect(result.current.dataViewState.apiFilter.filter).toEqual({ + 'evaluators.accuracy.mean': { $gte: 0.5, $lte: 0.9 }, + }), + { timeout: 2000 } + ); + }); +}); + +/** run_count is a flat scalar metric (not a `.` rollup), so the view's filter-field function + * returns undefined for it and the range filter must emit under the plain `run_count` key — + * matching the backend's `filter[run_count][$gte]=5` shape. */ +interface RunCountRow { + run_count?: number; +} + +const RUN_COUNT_DATA: RunCountRow[] = [{ run_count: 1 }, { run_count: 5 }, { run_count: 20 }]; + +function useRunCountHarness() { + // Mirrors ExperimentGroupDataView: run_count isn't remapped, so the function returns undefined. + const dataViewState = useStudioDataViewState({ filterFieldMap: () => undefined }); + const columns = useMakeColumns({ + makeColumns: (columnHelper) => [ + columnHelper.accessor((r) => r.run_count, { + id: 'run_count', + header: 'Run Count', + meta: { filter: numberRangeFilter('Run Count') }, + }), + ], + overrideToLoadingCells: false, + }); + const table = useCustomReactTable({ + columns, + data: RUN_COUNT_DATA, + dataMode: 'manual', + state: dataViewState, + totalCount: RUN_COUNT_DATA.length, + }); + return { dataViewState, table }; +} + +describe('useStudioDataViewState range-filter integration (flat run_count key)', () => { + it('emits a run_count range under its own flat key when the field function returns undefined', async () => { + const { result } = renderHook(() => useRunCountHarness(), { wrapper: MemoryRouter }); + + // Regression guard: numeric rows must still resolve to numberRange, not TanStack's inNumberRange. + expect(result.current.table.getColumn('run_count')?.columnDef.filterFn).toBe('numberRange'); + + act(() => { + result.current.table.getColumn('run_count')?.setFilterValue({ $gte: 5, $lte: 20 }); + }); + + await waitFor( + () => + expect(result.current.dataViewState.apiFilter.filter).toEqual({ + run_count: { $gte: 5, $lte: 20 }, + }), + { timeout: 2000 } + ); + }); +}); diff --git a/web/packages/common/src/hooks/useStudioDataViewState/index.test.tsx b/web/packages/common/src/hooks/useStudioDataViewState/index.test.tsx index b84a7e197c..c6748723a0 100644 --- a/web/packages/common/src/hooks/useStudioDataViewState/index.test.tsx +++ b/web/packages/common/src/hooks/useStudioDataViewState/index.test.tsx @@ -833,6 +833,43 @@ describe('useStudioDataViewState', () => { storage_type: 's3', }); }); + + it('should remap column filter ids via a function-form filterFieldMap', () => { + const filters = JSON.stringify([ + { id: 'evaluator-accuracy', value: { $gte: 0.8, $lte: 1 } }, + { id: 'cost_usd', value: { $lte: 0.5 } }, + ]); + const wrapper = createWrapper([`/?filters=${encodeURIComponent(filters)}`]); + + const { result } = renderHook( + () => + useStudioDataViewState({ + filterFieldMap: (id) => { + if (id === 'cost_usd') return 'cost_usd.mean'; + const match = id.match(/^evaluator-(.+)$/); + return match ? `evaluators.${match[1]}.mean` : undefined; + }, + }), + { wrapper } + ); + + expect(result.current.apiFilter.filter).toEqual({ + 'evaluators.accuracy.mean': { $gte: 0.8, $lte: 1 }, + 'cost_usd.mean': { $lte: 0.5 }, + }); + }); + + it('should leave a column filter id under its own id when the function returns undefined', () => { + const filters = JSON.stringify([{ id: 'storage_type', value: 's3' }]); + const wrapper = createWrapper([`/?filters=${encodeURIComponent(filters)}`]); + + const { result } = renderHook( + () => useStudioDataViewState({ filterFieldMap: () => undefined }), + { wrapper } + ); + + expect(result.current.apiFilter.filter).toEqual({ storage_type: 's3' }); + }); }); describe('resetFilters', () => { diff --git a/web/packages/common/src/hooks/useStudioDataViewState/index.ts b/web/packages/common/src/hooks/useStudioDataViewState/index.ts index 44b5322bc0..5b553dee0c 100644 --- a/web/packages/common/src/hooks/useStudioDataViewState/index.ts +++ b/web/packages/common/src/hooks/useStudioDataViewState/index.ts @@ -38,13 +38,9 @@ export interface UseStudioDataViewStateOptions extends Omit< * Example: { id: 'created_at', desc: true } for descending by created_at. */ defaultSort?: { id: string; desc: boolean }; - /** - * Maps a column filter id to the API filter key it should be emitted under. - * A column not present in the map is emitted under its own id (current behavior). - * Use for columns whose API field differs from the column id, e.g. - * `{ latency_ms: 'latency_ms.mean' }`. - */ - filterFieldMap?: Record; + /** Maps a filter column id to the API key it's emitted under (id used as-is when absent). Also + * accepts a function `(id) => key | undefined` for dynamic ids, e.g. `latency_ms`→`latency_ms.mean`. */ + filterFieldMap?: Record | ((id: string) => string | undefined); } /** @@ -498,7 +494,11 @@ export const useStudioDataViewState = >( return false; return true; }) - .map((f) => [filterFieldMap?.[f.id] ?? f.id, f.value]) + .map((f) => { + const mappedKey = + typeof filterFieldMap === 'function' ? filterFieldMap(f.id) : filterFieldMap?.[f.id]; + return [mappedKey ?? f.id, f.value]; + }) ) as Partial; } diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx index c49083190c..660ca89ac7 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx @@ -24,6 +24,7 @@ import { useExperimentGroupExperiments, } from '@studio/components/dataViews/ExperimentGroupDataView/useExperimentGroupExperiments'; import { useSortErrorRecovery } from '@studio/components/dataViews/ExperimentGroupDataView/useSortErrorRecovery'; +import { deriveEvaluatorNames } from '@studio/components/dataViews/ExperimentGroupDataView/util'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { getExperimentDetailRoute } from '@studio/routes/utils'; import { tooltipClassName } from '@studio/styles/common'; @@ -44,11 +45,14 @@ const STATIC_SORT_FIELD_MAP: Readonly> = { run_count: 'run_count', }; -// Maps filterable column ids to their API rollup-stat filter field. The dotted key -// (e.g. `latency_ms.mean`) is required by the backend's rollup-metric filter parser; -// the nested shape (`latency_ms.mean` split into objects) is rejected. -const FILTER_FIELD_MAP: Readonly> = { - latency_ms: 'latency_ms.mean', +// Maps a filter column id to its dotted API rollup-stat field (required by the backend parser). +// Evaluator ids are dynamic, so derive `evaluators..mean` here, like getExperimentSortParam. +const getExperimentFilterField = (id: string): string | undefined => { + if (id === 'cost_usd') return 'cost_usd.mean'; + if (id === 'latency_ms') return 'latency_ms.mean'; + const evaluatorMatch = id.match(/^evaluator-(.+)$/); + if (evaluatorMatch) return `evaluators.${evaluatorMatch[1]}.mean`; + return undefined; }; const getExperimentSortParam = ( @@ -101,7 +105,7 @@ export const ExperimentGroupDataView: FC = ({ columnVisibility: { created_by: false, updated_at: false }, // Keep the pin toggle reachable while horizontally scrolling this wide table. columnPinning: { left: ['pin'] }, - filterFieldMap: FILTER_FIELD_MAP, + filterFieldMap: getExperimentFilterField, }); const page = dataViewState.pagination.state.pageIndex + 1; @@ -136,11 +140,11 @@ export const ExperimentGroupDataView: FC = ({ onError: toast.error, }); - // One score column per evaluator: the union of evaluator names across the loaded rows, - // sorted for a deterministic column order across renders and page changes. + // One score column per evaluator: the names found across the loaded rows, plus any evaluator + // with an active filter (so its column survives a zero-result filter — see deriveEvaluatorNames). const evaluatorNames = useMemo( - () => [...new Set(orderedData.flatMap((e) => Object.keys(e.aggregate_scores ?? {})))].sort(), - [orderedData] + () => deriveEvaluatorNames(orderedData, dataViewState.debouncedColumnFilters), + [orderedData, dataViewState.debouncedColumnFilters] ); // One column per metadata key: keys are lowercased so case variants (e.g. "status" @@ -290,7 +294,7 @@ export const ExperimentGroupDataView: FC = ({ id: `evaluator-${name}`, header: `Avg ${title}`, enableSorting: true, - meta: { title: false }, + meta: { title: false, filter: numberRangeFilter(`Avg ${title}`) }, size: 140, cell: ({ row }) => { const score = row.original.aggregate_scores?.[name]; @@ -311,7 +315,7 @@ export const ExperimentGroupDataView: FC = ({ id: 'cost_usd', header: 'Avg Cost', enableSorting: true, - meta: { title: false }, + meta: { title: false, filter: numberRangeFilter('Avg Cost') }, cell: ({ row }) => { const { cost_usd, run_count } = row.original; return ( @@ -349,6 +353,7 @@ export const ExperimentGroupDataView: FC = ({ id: 'run_count', header: 'Run Count', enableSorting: true, + meta: { filter: numberRangeFilter('Run Count') }, cell: ({ row }) => {String(row.original.run_count ?? 0)}, }), accessor('created_at', { diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.test.ts b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.test.ts new file mode 100644 index 0000000000..a6939b1067 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.test.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { deriveEvaluatorNames } from '@studio/components/dataViews/ExperimentGroupDataView/util'; + +describe('deriveEvaluatorNames', () => { + it('returns the sorted, de-duplicated union of evaluator names across rows', () => { + const rows = [ + { aggregate_scores: { helpfulness: { mean: 0.8 }, accuracy: { mean: 0.9 } } }, + { aggregate_scores: { accuracy: { mean: 0.5 } } }, + {}, // a row with no scores + ]; + + expect(deriveEvaluatorNames(rows, [])).toEqual(['accuracy', 'helpfulness']); + }); + + // Regression: a zero-result evaluator filter empties the rows, which would otherwise drop the + // dynamic column and hide its filter chip/panel entry while the filter persists in state and URL. + it('keeps an evaluator with an active filter even when no rows match', () => { + expect(deriveEvaluatorNames([], [{ id: 'evaluator-accuracy' }])).toEqual(['accuracy']); + }); + + it('unions data-derived names with active-filter names', () => { + const rows = [{ aggregate_scores: { accuracy: { mean: 0.9 } } }]; + + expect(deriveEvaluatorNames(rows, [{ id: 'evaluator-helpfulness' }])).toEqual([ + 'accuracy', + 'helpfulness', + ]); + }); + + it('ignores non-evaluator filters (cost, latency, text columns)', () => { + const filters = [ + { id: 'cost_usd' }, + { id: 'latency_ms' }, + { id: 'dataset_name' }, + { id: 'evaluator-accuracy' }, + ]; + + expect(deriveEvaluatorNames([], filters)).toEqual(['accuracy']); + }); + + it('handles evaluator names containing hyphens', () => { + expect(deriveEvaluatorNames([], [{ id: 'evaluator-tool-use-quality' }])).toEqual([ + 'tool-use-quality', + ]); + }); +}); diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.ts b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.ts new file mode 100644 index 0000000000..dfdd545fa5 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/util.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Evaluator names in the rows, unioned with any that have an active filter so a column (and its + * filter-panel entry / applied-filter chip) survives a zero-result filter. Sorted for stable order. */ +export const deriveEvaluatorNames = ( + rows: readonly { aggregate_scores?: Record }[], + columnFilters: readonly { id: string }[] +): string[] => { + const fromData = rows.flatMap((row) => Object.keys(row.aggregate_scores ?? {})); + const fromFilters = columnFilters + .map((filter) => filter.id.match(/^evaluator-(.+)$/)?.[1]) + .filter((name): name is string => name != null); + return [...new Set([...fromData, ...fromFilters])].sort(); +};