diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 386598e5cf..d42441386b 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -10007,6 +10007,11 @@ components: title: Updated At type: string format: date-time + experiment_count: + type: integer + title: Experiment Count + description: Number of live (non-soft-deleted) experiments in this group. + default: 0 type: object required: - id diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 386598e5cf..d42441386b 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -10007,6 +10007,11 @@ components: title: Updated At type: string format: date-time + experiment_count: + type: integer + title: Experiment Count + description: Number of live (non-soft-deleted) experiments in this group. + default: 0 type: object required: - id diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 386598e5cf..d42441386b 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -10007,6 +10007,11 @@ components: title: Updated At type: string format: date-time + experiment_count: + type: integer + title: Experiment Count + description: Number of live (non-soft-deleted) experiments in this group. + default: 0 type: object required: - id diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 386598e5cf..d42441386b 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -10007,6 +10007,11 @@ components: title: Updated At type: string format: date-time + experiment_count: + type: integer + title: Experiment Count + description: Number of live (non-soft-deleted) experiments in this group. + default: 0 type: object required: - id diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/experiment_groups/experiment_group_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/experiment_groups/experiment_group_response.py index 2a8aa6ed06..04e48f3069 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/experiment_groups/experiment_group_response.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/experiment_groups/experiment_group_response.py @@ -36,4 +36,7 @@ class ExperimentGroupResponse(BaseModel): description: Optional[str] = None + experiment_count: Optional[int] = None + """Number of live (non-soft-deleted) experiments in this group.""" + updated_at: Optional[datetime] = None diff --git a/services/intake/src/nmp/intake/api/v2/experiments/endpoints.py b/services/intake/src/nmp/intake/api/v2/experiments/endpoints.py index 3995ee08e3..1379462b9a 100644 --- a/services/intake/src/nmp/intake/api/v2/experiments/endpoints.py +++ b/services/intake/src/nmp/intake/api/v2/experiments/endpoints.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import logging import secrets import time @@ -145,8 +146,17 @@ async def list_experiment_groups( page=page, page_size=page_size, ) + responses = [ExperimentGroupResponse.from_entity(e) for e in result.data] + # One count query per group, fanned out in parallel. Linear in the page size (up to 1000) + # but parallelized, so wall-clock stays low. If group counts become a hot path, replace + # with a bulk aggregate on the entity store rather than scaling this gather wider. + counts = await asyncio.gather( + *[_count_live_experiments_in_group(entity_client, workspace=workspace, group_id=g.id) for g in result.data] + ) + for response, count in zip(responses, counts): + response.experiment_count = count return Page( - data=[ExperimentGroupResponse.from_entity(e) for e in result.data], + data=responses, pagination=PaginationData(**result.pagination.model_dump()), sort=sort, filter=parsed.to_response(), @@ -172,7 +182,11 @@ async def get_experiment_group( label="Experiment group", ) _reject_if_deleted(entity, workspace=workspace, name=name, label="Experiment group") - return ExperimentGroupResponse.from_entity(entity) + response = ExperimentGroupResponse.from_entity(entity) + response.experiment_count = await _count_live_experiments_in_group( + entity_client, workspace=workspace, group_id=entity.id + ) + return response @router.put( @@ -205,7 +219,11 @@ async def update_experiment_group( ) existing.description = body.description updated = await entity_client.update(existing) - return ExperimentGroupResponse.from_entity(updated) + response = ExperimentGroupResponse.from_entity(updated) + response.experiment_count = await _count_live_experiments_in_group( + entity_client, workspace=workspace, group_id=updated.id + ) + return response @router.delete( @@ -601,6 +619,33 @@ async def _soft_delete(entity_client: EntityClient, entity: Experiment | Experim await entity_client.update(entity, original_name=original_name) +async def _count_live_experiments_in_group(entity_client: EntityClient, *, workspace: str, group_id: str) -> int: + """Return the number of non-soft-deleted experiments in a group. + + Fetches via ``list(page_size=1)`` so the response carries only ``pagination.total_results`` — + cheap enough to fan out per-group via ``asyncio.gather`` on the list endpoint. + """ + result = await entity_client.list( + Experiment, + workspace=workspace, + filter_operation=LogicalOperation( + operator=FilterOperator.AND, + operations=[ + ComparisonOperation(operator=FilterOperator.EQ, field="data.experiment_group_id", value=group_id), + LogicalOperation( + operator=FilterOperator.NOT, + operations=[ + ComparisonOperation(operator=FilterOperator.EQ, field="data.is_deleted", value=True), + ], + ), + ], + ), + page=1, + page_size=1, + ) + return result.pagination.total_results + + async def _validate_group_exists(entity_client: EntityClient, *, group_id: str) -> None: """Reject the request with 400 if the referenced ExperimentGroup doesn't exist or is deleted.""" try: diff --git a/services/intake/src/nmp/intake/api/v2/experiments/schemas.py b/services/intake/src/nmp/intake/api/v2/experiments/schemas.py index bff2e7f064..1d34ffc8f0 100644 --- a/services/intake/src/nmp/intake/api/v2/experiments/schemas.py +++ b/services/intake/src/nmp/intake/api/v2/experiments/schemas.py @@ -54,6 +54,10 @@ class ExperimentGroupResponse(BaseModel): description: str | None = None created_at: datetime | None = None updated_at: datetime | None = None + experiment_count: int = Field( + default=0, + description="Number of live (non-soft-deleted) experiments in this group.", + ) @classmethod def from_entity(cls, entity: ExperimentGroup) -> ExperimentGroupResponse: diff --git a/web/packages/studio/src/components/ExperimentGroupCreateModal/index.tsx b/web/packages/studio/src/components/ExperimentGroupCreateModal/index.tsx index a5a38f5f62..63f6e34a06 100644 --- a/web/packages/studio/src/components/ExperimentGroupCreateModal/index.tsx +++ b/web/packages/studio/src/components/ExperimentGroupCreateModal/index.tsx @@ -130,7 +130,7 @@ export const ExperimentGroupCreateModal: FC = ( > - Create experiment + Create group Coding agent CLI command diff --git a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx index 97f2f0e417..907f2f4359 100644 --- a/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx +++ b/web/packages/studio/src/components/dataViews/ExperimentGroupDataView/index.tsx @@ -126,7 +126,18 @@ export const ExperimentGroupDataView: FC = ({ const { name, summary } = row.original; if (!summary) return {name}; return ( - + + + Summary + + {summary} + + } + className={tooltipClassName} + side="bottom" + > {name} ); @@ -166,7 +177,7 @@ export const ExperimentGroupDataView: FC = ({ }), accessor((original) => original.model_names?.join(', '), { id: 'model_names', - header: 'Model Names', + header: 'Models', enableSorting: false, cell: ({ getValue }) => {getValue() || '-'}, }), diff --git a/web/packages/studio/src/routes/ExperimentDetailRoute/ExperimentDetailMetrics.tsx b/web/packages/studio/src/routes/ExperimentDetailRoute/ExperimentDetailMetrics.tsx index 14f48d6323..0c97a8ad15 100644 --- a/web/packages/studio/src/routes/ExperimentDetailRoute/ExperimentDetailMetrics.tsx +++ b/web/packages/studio/src/routes/ExperimentDetailRoute/ExperimentDetailMetrics.tsx @@ -4,9 +4,10 @@ import { KVPair } from '@nemo/common/src/components/KVPair'; import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; import { useGetExperiment } from '@nemo/sdk/generated/platform/api'; -import { Divider } from '@nvidia/foundations-react-core'; +import { Divider, Flex, Text, Tooltip } from '@nvidia/foundations-react-core'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { type FC } from 'react'; +import { tooltipClassName } from '@studio/styles/common'; +import { type FC, type ReactNode } from 'react'; interface ExperimentDetailMetricsProps { experimentName: string; @@ -24,36 +25,61 @@ export const ExperimentDetailMetrics: FC = ({ expe ? `${Math.round(experiment.latency_ms.mean)} ms` : undefined; + const modelNames = experiment?.model_names ?? []; + const modelNamesJoined = modelNames.length > 0 ? modelNames.join(', ') : undefined; + const modelNamesValue: ReactNode = modelNamesJoined ? ( + modelNames.length > 1 ? ( + // Truncate + tooltip for the multi-model case to keep the header KV row compact. + + {modelNamesJoined} + + ) : ( + modelNamesJoined + ) + ) : undefined; + return ( -
- - - : undefined - } - loading={isLoading} - orientation="vertical" - /> - - : undefined - } - loading={isLoading} - orientation="vertical" - /> - - - - -
+ + + + + + + : undefined + } + loading={isLoading} + orientation="vertical" + /> + + : undefined + } + loading={isLoading} + orientation="vertical" + /> + + + + + + + + + ); }; diff --git a/web/packages/studio/src/routes/ExperimentDetailRoute/index.tsx b/web/packages/studio/src/routes/ExperimentDetailRoute/index.tsx index 59aab03136..7fa666471e 100644 --- a/web/packages/studio/src/routes/ExperimentDetailRoute/index.tsx +++ b/web/packages/studio/src/routes/ExperimentDetailRoute/index.tsx @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { useGetExperiment } from '@nemo/sdk/generated/platform/api'; +import { Badge, PageHeader, Stack, Text } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { ExperimentSessionsDataView } from '@studio/components/dataViews/ExperimentSessionsDataView'; import { ROUTE_PARAMS } from '@studio/constants/routes'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; import { ExperimentDetailMetrics } from '@studio/routes/ExperimentDetailRoute/ExperimentDetailMetrics'; -import { getExperimentRoute, getExperimentGroupDetailRoute } from '@studio/routes/utils'; +import { getExperimentGroupDetailRoute, getExperimentRoute } from '@studio/routes/utils'; import { useRequiredPathParams } from '@studio/util/hooks/useRequiredPathParams'; import { type FC } from 'react'; @@ -18,10 +19,11 @@ export const ExperimentDetailRoute: FC = () => { ROUTE_PARAMS.experimentGroupName, ROUTE_PARAMS.experimentName, ]); + const { data: experiment } = useGetExperiment(workspace, experimentName); useBreadcrumbs({ items: [ - { href: getExperimentRoute(workspace), slotLabel: 'Experiments' }, + { href: getExperimentRoute(workspace), slotLabel: 'Experiment Groups' }, { href: getExperimentGroupDetailRoute(workspace, experimentGroupName), slotLabel: experimentGroupName, @@ -35,10 +37,20 @@ export const ExperimentDetailRoute: FC = () => { - +
+
+ Test cases + {experiment?.run_count !== undefined && ( + + {experiment.run_count} + + )} +
+ +
); diff --git a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx index fd8fd11445..b6de6e92aa 100644 --- a/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx +++ b/web/packages/studio/src/routes/ExperimentGroupDetailRoute/index.tsx @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { useGetExperimentGroup } from '@nemo/sdk/generated/platform/api'; +import { Badge, PageHeader, Stack, Text } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { ExperimentGroupDataView } from '@studio/components/dataViews/ExperimentGroupDataView'; import { ROUTE_PARAMS } from '@studio/constants/routes'; @@ -15,10 +16,11 @@ import { type FC } from 'react'; export const ExperimentGroupDetailRoute: FC = () => { const workspace = useWorkspaceFromPath(); const { experimentGroupName } = useRequiredPathParams([ROUTE_PARAMS.experimentGroupName]); + const { data: group } = useGetExperimentGroup(workspace, experimentGroupName); useBreadcrumbs({ items: [ - { href: getExperimentRoute(workspace), slotLabel: 'Experiments' }, + { href: getExperimentRoute(workspace), slotLabel: 'Experiment Groups' }, { slotLabel: experimentGroupName }, ], }); @@ -26,9 +28,23 @@ export const ExperimentGroupDetailRoute: FC = () => { return ( - + - +
+
+ Experiments + {group?.experiment_count !== undefined && ( + + {group.experiment_count} + + )} +
+ +
); diff --git a/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx b/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx index ed0806a5d6..040beb1011 100644 --- a/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx +++ b/web/packages/studio/src/routes/ExperimentRoute/ExperimentGroupCard.tsx @@ -1,9 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useListExperiments } from '@nemo/sdk/generated/platform/api'; import type { ExperimentGroupResponse } from '@nemo/sdk/generated/platform/schema'; -import { Card, Divider, Text } from '@nvidia/foundations-react-core'; +import { Card, Text } from '@nvidia/foundations-react-core'; import { Metric } from '@studio/routes/ExperimentRoute/Metric'; import { UpdatedAt } from '@studio/routes/ExperimentRoute/UpdatedAt'; import { getExperimentGroupDetailRoute } from '@studio/routes/utils'; @@ -18,28 +17,6 @@ interface ExperimentGroupCardProps { export const ExperimentGroupCard: FC = ({ group, workspace }) => { const navigate = useNavigate(); - const { data: experimentsData } = useListExperiments(workspace, { - filter: { experiment_group_id: group.id }, - page_size: 100, - }); - - const experiments = experimentsData?.data ?? []; - const experimentCount = experimentsData?.pagination?.total_results ?? experiments.length; - - // Collect evaluator names and average their means across experiments in this group - const evaluatorNames = [ - ...new Set(experiments.flatMap((e) => Object.keys(e.aggregate_scores ?? {}))), - ]; - const scoreEntries = evaluatorNames - .map((name) => { - const means = experiments - .map((e) => e.aggregate_scores?.[name]?.mean) - .filter((v): v is number => v !== undefined && v !== null); - const avg = means.length > 0 ? means.reduce((a, b) => a + b, 0) / means.length : null; - return { name, avg }; - }) - .filter((entry): entry is { name: string; avg: number } => entry.avg !== null); - return ( = ({ group, works {/* Stats */}
- {scoreEntries.map(({ name, avg }) => ( - - ))} - {scoreEntries.length > 0 && } - +
); diff --git a/web/packages/studio/src/routes/ExperimentRoute/index.tsx b/web/packages/studio/src/routes/ExperimentRoute/index.tsx index f32ea4bf07..27cb53ea35 100644 --- a/web/packages/studio/src/routes/ExperimentRoute/index.tsx +++ b/web/packages/studio/src/routes/ExperimentRoute/index.tsx @@ -31,7 +31,7 @@ import { type FC, useState } from 'react'; const DEFAULT_PAGE_SIZE = 5; export const ExperimentRoute: FC = () => { - useBreadcrumbs({ items: [{ slotLabel: 'Experiments' }] }); + useBreadcrumbs({ items: [{ slotLabel: 'Experiment Groups' }] }); const workspace = useWorkspaceFromPath(); const [page, setPage] = useState(1);