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
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.

95 changes: 92 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 @@ -145,8 +145,14 @@ async def list_experiment_groups(
page=page,
page_size=page_size,
)
responses = [ExperimentGroupResponse.from_entity(e) for e in result.data]
counts = await _count_live_experiments_by_group(
entity_client, workspace=workspace, group_ids=[g.id for g in result.data]
)
for response in responses:
response.experiment_count = counts.get(response.id, 0)
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 +178,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 +215,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 +615,81 @@ 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 single group.

Fetches via ``list(page_size=1)`` so the response carries only ``pagination.total_results``.
Used by single-group endpoints (GET, PUT). List endpoints should use the bulk variant.
"""
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 _count_live_experiments_by_group(
entity_client: EntityClient, *, workspace: str, group_ids: list[str]
) -> dict[str, int]:
"""Bulk-count non-soft-deleted experiments for many groups in one (paginated) query.

Issues a single ``IN``-filter list against the entity store and tallies per group_id
client-side. Replaces N parallel ``_count_live_experiments_in_group`` calls on the
group-list endpoint so the request shape is 1-to-1 with the entity store rather than
1-to-N (which is fragile under web-server concurrency).

Returns a ``{group_id: count}`` map covering every requested group_id, with ``0`` for
groups that have no live experiments.
"""
counts: dict[str, int] = {group_id: 0 for group_id in group_ids}
if not group_ids:
return counts
page = 1
# Aligned with ``EntityClient.list``'s max — paginates when a workspace's total live
# experiment count across the requested groups exceeds this.
page_size = 1000
filter_operation = LogicalOperation(
operator=FilterOperator.AND,
operations=[
ComparisonOperation(operator=FilterOperator.IN, field="data.experiment_group_id", value=list(group_ids)),
LogicalOperation(
operator=FilterOperator.NOT,
operations=[
ComparisonOperation(operator=FilterOperator.EQ, field="data.is_deleted", value=True),
],
),
],
)
while True:
result = await entity_client.list(
Experiment,
workspace=workspace,
filter_operation=filter_operation,
page=page,
page_size=page_size,
)
for experiment in result.data:
counts[experiment.experiment_group_id] = counts.get(experiment.experiment_group_id, 0) + 1
if page >= result.pagination.total_pages:
break
page += 1
return counts


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