Skip to content
Open
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
5 changes: 5 additions & 0 deletions openapi/ga/individual/platform.openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions openapi/ga/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions openapi/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions sdk/python/nemo-platform/.nmpcontext/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 48 additions & 3 deletions services/intake/src/nmp/intake/api/v2/experiments/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import asyncio
import logging
import secrets
import time
Expand Down Expand Up @@ -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]
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(),
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@router.put(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const ExperimentGroupCreateModal: FC<ExperimentGroupCreateModalProps> = (
>
<TabsRoot defaultValue="create" className="w-full min-w-0">
<TabsList>
<TabsTrigger value="create">Create experiment</TabsTrigger>
<TabsTrigger value="create">Create group</TabsTrigger>
<TabsTrigger value="coding-agent">Coding agent</TabsTrigger>
<TabsTrigger value="cli">CLI command</TabsTrigger>
</TabsList>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,18 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
const { name, summary } = row.original;
if (!summary) return <Text>{name}</Text>;
return (
<Tooltip slotContent={summary} className={tooltipClassName} side="bottom">
<Tooltip
slotContent={
<div className="flex flex-col gap-1">
<Text kind="label/regular/sm" className="text-secondary">
Summary
</Text>
<Text kind="body/regular/sm">{summary}</Text>
</div>
}
className={tooltipClassName}
side="bottom"
>
<Text className="cursor-default border-b border-dotted border-brand">{name}</Text>
</Tooltip>
);
Expand Down Expand Up @@ -166,7 +177,7 @@ export const ExperimentGroupDataView: FC<ExperimentGroupDataViewProps> = ({
}),
accessor((original) => original.model_names?.join(', '), {
id: 'model_names',
header: 'Model Names',
header: 'Models',
enableSorting: false,
cell: ({ getValue }) => <Text>{getValue<string>() || '-'}</Text>,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,36 +25,61 @@ export const ExperimentDetailMetrics: FC<ExperimentDetailMetricsProps> = ({ 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.
<Tooltip slotContent={modelNamesJoined} className={tooltipClassName} side="bottom">
<Text className="cursor-default truncate max-w-[200px] block">{modelNamesJoined}</Text>
</Tooltip>
) : (
modelNamesJoined
)
) : undefined;

return (
<div className="flex gap-8">
<KVPair
label="Agent Names"
value={experiment?.agent_names?.join(', ') || undefined}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Created"
value={
experiment?.created_at ? <RelativeTime datetime={experiment.created_at} /> : undefined
}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Updated"
value={
experiment?.updated_at ? <RelativeTime datetime={experiment.updated_at} /> : undefined
}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair label="Avg Cost" value={avgCost} loading={isLoading} orientation="vertical" />
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair label="Avg Latency" value={avgLatency} loading={isLoading} orientation="vertical" />
</div>
<Flex align="stretch" justify="between" gap="density-3xl">
<Flex align="stretch" gap="density-3xl">
<KVPair
label="Dataset Name"
value={experiment?.dataset_name || undefined}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Dataset Version"
value={experiment?.dataset_version || undefined}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Created"
value={
experiment?.created_at ? <RelativeTime datetime={experiment.created_at} /> : undefined
}
loading={isLoading}
orientation="vertical"
/>
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair
label="Updated"
value={
experiment?.updated_at ? <RelativeTime datetime={experiment.updated_at} /> : undefined
}
loading={isLoading}
orientation="vertical"
/>
</Flex>
<Flex align="stretch" gap="density-3xl">
<KVPair label="Models" value={modelNamesValue} loading={isLoading} orientation="vertical" />
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair label="Avg Cost" value={avgCost} loading={isLoading} orientation="vertical" />
<Divider orientation="vertical" className="grow-0 self-stretch" />
<KVPair label="Avg Latency" value={avgLatency} loading={isLoading} orientation="vertical" />
</Flex>
</Flex>
);
};
26 changes: 19 additions & 7 deletions web/packages/studio/src/routes/ExperimentDetailRoute/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand All @@ -35,10 +37,20 @@ export const ExperimentDetailRoute: FC = () => {
<Stack className="h-full overflow-auto" gap="density-2xl" padding="density-2xl">
<PageHeader className="p-0" slotHeading={experimentName} />
<ExperimentDetailMetrics experimentName={experimentName} />
<ExperimentSessionsDataView
experimentName={experimentName}
experimentGroupName={experimentGroupName}
/>
<div className="flex flex-col gap-4 border-t border-base pt-4">
<div className="flex items-center gap-3">
<Text kind="title/sm">Test cases</Text>
{experiment?.run_count !== undefined && (
<Badge color="gray" kind="solid" className="text-sm">
{experiment.run_count}
</Badge>
)}
</div>
<ExperimentSessionsDataView
experimentName={experimentName}
experimentGroupName={experimentGroupName}
/>
</div>
</Stack>
</AccessibleTitle>
);
Expand Down
Loading
Loading