Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,121 @@ describe('useStudioDataViewState range-filter integration', () => {
);
});
});

/** Evaluator columns use dynamic `evaluator-<name>` ids, so the view passes a function-form
* `filterFieldMap` deriving `evaluators.<name>.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<EvaluatorRow>({
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<EvaluatorRow>({
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 `.<stat>` 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<RunCountRow>({
makeColumns: (columnHelper) => [
columnHelper.accessor((r) => r.run_count, {
id: 'run_count',
header: 'Run Count',
meta: { filter: numberRangeFilter('Run Count') },
}),
],
overrideToLoadingCells: false,
});
const table = useCustomReactTable<RunCountRow>({
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 }
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 8 additions & 8 deletions web/packages/common/src/hooks/useStudioDataViewState/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** 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<string, string> | ((id: string) => string | undefined);
}

/**
Expand Down Expand Up @@ -498,7 +494,11 @@ export const useStudioDataViewState = <FilterType = Record<string, unknown>>(
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<FilterType>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -44,11 +45,14 @@ const STATIC_SORT_FIELD_MAP: Readonly<Record<string, string>> = {
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<Record<string, string>> = {
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.<name>.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 = (
Expand Down Expand Up @@ -101,7 +105,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
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;
Expand Down Expand Up @@ -136,11 +140,11 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
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"
Expand Down Expand Up @@ -290,7 +294,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
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];
Expand All @@ -311,7 +315,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
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 (
Expand Down Expand Up @@ -349,6 +353,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
id: 'run_count',
header: 'Run Count',
enableSorting: true,
meta: { filter: numberRangeFilter('Run Count') },
cell: ({ row }) => <Text>{String(row.original.run_count ?? 0)}</Text>,
}),
accessor('created_at', {
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[],
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();
};