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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- The experiment page's graphs row (the pass/k curve and leaderboard) gains an automatic **Pareto frontier** card: one point per agent cohort plotting pass@1 (the same per-task-averaged estimator the leaderboard ranks by) against average cost per trial, with a toggle to swap the x-axis to cost per success (total spend over priced trials ÷ passes among them), average tokens (input + output), time (trajectory clock, wall clock as fallback), agent steps, or tool calls per trial — each metric re-derives its own frontier, since an agent can be non-dominated on cost yet dominated on time. Non-dominated agents are joined by a dashed frontier curve and direct-labeled; hiding an agent via the shared legend re-derives the frontier over the remaining cohorts, and the cost axis carries the existing `~`/`*` estimated-cost marks in its tooltip. The card renders whenever any trial reports the relevant metric, and the header toggle is renamed from "Pass/k graph" to "Graphs" to match its wider scope. The Pareto card lives under a separate header **Eval** toggle alongside a task × agent **solve grid** (per-task pass rate as a status-colored heatmap, hardest tasks first, not-run cells distinguished from failed ones). The Eval toggle and everything under it sit behind a hardcoded allowlist (`EVAL_GRAPHS_USER_ALLOWLIST` in `lib/eval-graphs.ts`, currently meji@abundant.ai), so other users — and public share views, which have no signed-in viewer — see neither the button nor the cards.
- The experiment page's graphs row (the pass/k curve and leaderboard) gains an automatic **Pareto frontier** card: one point per agent cohort plotting pass@1 (the same per-task-averaged estimator the leaderboard ranks by) against average cost per trial, with a toggle to swap the x-axis to cost per success (total spend over priced trials ÷ passes among them), average tokens (input + output), time (trajectory clock, wall clock as fallback), agent steps, or tool calls per trial — each metric re-derives its own frontier, since an agent can be non-dominated on cost yet dominated on time. Non-dominated agents are joined by a dashed frontier curve and direct-labeled; hiding an agent via the shared legend re-derives the frontier over the remaining cohorts, and the cost axis carries the existing `~`/`*` estimated-cost marks in its tooltip. The card renders whenever any trial reports the relevant metric, and the header toggle is renamed from "Pass/k graph" to "Graphs" to match its wider scope. The Pareto card lives under a separate header **Eval** toggle alongside two more experimental cards: a task × agent **solve grid** (per-task pass rate as a status-colored heatmap, hardest tasks first, not-run cells distinguished from failed ones) and a **stat polygon** — one radar with every shown agent's polygon overlaid (absolute pass@1 plus cheap/light/fast/lean efficiency axes normalized to the best shown agent, outer edge = best), sharing the row's legend, hide, and hover cross-highlight; the tooltip reports the raw per-axis values with the `~`/`*` estimate marks. The Eval toggle and everything under it sit behind a hardcoded allowlist (`EVAL_GRAPHS_USER_ALLOWLIST` in `lib/eval-graphs.ts`, currently meji@abundant.ai), so other users — and public share views, which have no signed-in viewer — see neither the button nor the cards.

- `opencode` trials can now run on closed-internet tasks. Stock opencode self-installs (nvm/Node/`opencode-ai`) during agent SETUP, which runs under the ENVIRONMENT baseline network policy — the agent-phase allowlist (`extra_allowed_hosts`, runtime-host merges) only applies around `agent.run()`, so no agent-phase declaration can save a self-installing agent: the trial died at DNS during setup (`curl: (6) Could not resolve host: raw.githubusercontent.com`) before the model was ever reached. The fix mirrors the existing claude-code installer arm in `run_harbor_trial_async`: `-a opencode` now merges `OPENCODE_INSTALL_HOSTS` plus the model transport host (via `outbound_hosts_for_model`, which resolves `openrouter/tencent/hy3` → `openrouter.ai`) into `env_config.extra_allowed_hosts`, which harbor folds into the environment baseline so the allowlist spans install *and* run. On legacy closed tasks (`[environment] allow_internet=false` → no-network baseline for every phase, e.g. the GDM SWE-Marathon samples) this is the only channel that works at all; on modern swe-marathon-shaped tasks (public setup → restricted agent) harbor ignores baseline extras on the public baseline and the agent phase keeps its model-host-only allowlist, so no install hosts leak into agent run there. `_build_agent_config` still routes `-a opencode` through the `OddishOpenCode` wrapper. Note: `required_outbound_domains` — the hook two earlier revisions of this change relied on, and which several wrapper docstrings describe as "Harbor builds the Modal egress allowlist from this hook" — has **no consumer** in oddish or harbor; it is kept declarative-only for interface parity (both failed approaches were validated end-to-end on the PR preview backend before landing on this one).

Expand Down
296 changes: 296 additions & 0 deletions frontend/src/components/agent-stat-radar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
"use client";

import { memo, useCallback, useMemo } from "react";
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
import type { TooltipContentProps } from "recharts";
import type { Task } from "@/lib/types";
import { buildAgentParetoPoints } from "@/lib/pareto";
import {
costEstimateMarks,
formatCostUsd,
formatDurationSec,
formatTokenCount,
} from "@/lib/format";
import type { ExperimentAgentSummary } from "@/lib/experiment-agent-grouping";
import { useElementSize } from "@/lib/use-element-size";
import { AGENT_COLORS } from "./pass-at-k-graph";
import { AgentLegend } from "@/components/agent-legend";

interface AgentStatRadarProps {
tasks: Task[];
agentSummaries: ExperimentAgentSummary[];
hiddenAgents: Set<string>;
onToggleAgent: (agent: string) => void;
hoverAgent?: string | null;
onHoverAgent?: (key: string | null) => void;
}

type TooltipValue = number | string | ReadonlyArray<number | string>;
type TooltipName = number | string;

// The radar's lower-is-better axes: shown as efficiency relative to the best
// visible agent (min/value, outer edge = best). Score stays absolute pass@1.
const EFFICIENCY_AXES = [
{ axis: "cheap", metric: "cost", format: formatCostUsd },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sub-cent costs show as free

Medium Severity

The cheap-axis tooltip formats per-trial mean cost with formatCostUsd, which floors values under half a cent to $0.00. That hides the real dollars the normalized polygon is based on, and can make two differently placed agents both read as free. The adjacent Pareto card already formats the same means with four decimals for this case.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c9d278b. Configure here.

{ axis: "light", metric: "tokens", format: formatTokenCount },
{ axis: "fast", metric: "time", format: formatDurationSec },
{
axis: "lean",
metric: "steps",
format: (v: number) => `${Math.round(v)} steps`,
},
] as const;

type AxisRow = { axis: string } & Record<string, number | string>;

export const AgentStatRadar = memo(function AgentStatRadar({
tasks,
agentSummaries,
hiddenAgents,
onToggleAgent,
hoverAgent,
onHoverAgent,
}: AgentStatRadarProps) {
const { ref: chartContainerRef, size: chartSize } =
useElementSize<HTMLDivElement>();

const { axisRows, visibleKeys, rawByAxis, agentColorByKey, agentLabelByKey } =
useMemo(() => {
const colorMap: Record<string, string> = {};
const labelMap: Record<string, string> = {};
for (let i = 0; i < agentSummaries.length; i++) {
colorMap[agentSummaries[i].key] = AGENT_COLORS[i % AGENT_COLORS.length];
labelMap[agentSummaries[i].key] = agentSummaries[i].label;
}

const points = buildAgentParetoPoints(tasks, agentSummaries).filter(
(point) => !hiddenAgents.has(point.key)
);

// Per-metric best among the shown agents; axes nobody reports are
// dropped so every polygon spans the same spokes. Hiding an outlier
// re-normalizes the rest, like the Pareto frontier does.
const minByMetric = new Map<string, number>();
for (const { metric } of EFFICIENCY_AXES) {
const values = points
.map((point) => point.metrics[metric]?.value)
.filter((value): value is number => value != null && value > 0);
if (values.length > 0) minByMetric.set(metric, Math.min(...values));
}

const raw = new Map<string, Map<string, string>>();
const scoreRow: AxisRow = { axis: "score" };
const scoreRaw = new Map<string, string>();
for (const point of points) {
scoreRow[point.key] = point.score;
scoreRaw.set(point.key, `${(point.score * 100).toFixed(1)}%`);
}
raw.set("score", scoreRaw);

const rows: AxisRow[] = [scoreRow];
for (const { axis, metric, format } of EFFICIENCY_AXES) {
const min = minByMetric.get(metric);
if (min == null) continue;
const row: AxisRow = { axis };
const axisRaw = new Map<string, string>();
for (const point of points) {
const value = point.metrics[metric]?.value;
// Unreported reads as 0 (tooltip says so); zero-cost is maximal
// efficiency, not a division blowup.
row[point.key] =
value == null ? 0 : value <= 0 ? 1 : Math.min(1, min / value);
const marks =
metric === "cost"
? costEstimateMarks(point.costHasEstimated, point.costHasNative)
: { prefix: "", suffix: "" };
axisRaw.set(
point.key,
value == null
? "not reported"
: `${marks.prefix}${format(value)}${marks.suffix}`
);
}
raw.set(axis, axisRaw);
rows.push(row);
}

return {
axisRows: rows,
visibleKeys: points.map((point) => point.key),
rawByAxis: raw,
agentColorByKey: colorMap,
agentLabelByKey: labelMap,
};
}, [tasks, agentSummaries, hiddenAgents]);

const renderTooltip = useCallback(
(props: TooltipContentProps<TooltipValue, TooltipName>) => {
const { active, payload, label } = props;
if (!active || !payload || payload.length === 0) return null;
const axisRaw = rawByAxis.get(String(label));

const sorted = [...payload]
.filter((entry) => typeof entry.value === "number")
.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0));

return (
<div
style={{
backgroundColor: "var(--paper-surface)",
border: "1px solid var(--paper-line)",
borderRadius: "8px",
padding: "8px 12px",
fontSize: "11.5px",
fontFamily: "var(--font-geist-mono), ui-monospace, monospace",
boxShadow: "0 4px 14px rgba(0,0,0,0.08)",
color: "var(--paper-ink)",
}}
>
<div
style={{
marginBottom: "4px",
fontWeight: 600,
color: "var(--paper-ink-2)",
}}
>
{label}
</div>
{sorted.map((entry) => {
const key = entry.dataKey as string;
const isHovered = hoverAgent != null && hoverAgent === key;
return (
<div
key={key}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "1px 0",
whiteSpace: "nowrap",
fontWeight: isHovered ? 600 : 400,
}}
>
<span
style={{
width: "8px",
height: "8px",
borderRadius: "2px",
backgroundColor:
agentColorByKey[key] ?? "var(--paper-ink-3)",
flexShrink: 0,
}}
/>
<span style={{ color: "var(--paper-ink-2)" }}>
{agentLabelByKey[key] ?? key}
</span>
<span
style={{
marginLeft: "auto",
paddingLeft: "12px",
fontWeight: 500,
color: "var(--paper-ink)",
}}
>
{axisRaw?.get(key) ?? ""}
</span>
</div>
);
})}
</div>
);
},
[rawByAxis, agentColorByKey, agentLabelByKey, hoverAgent]
);

if (visibleKeys.length === 0 || axisRows.length < 3) {
return null;
}

return (
<div className="flex h-full min-w-0 flex-col rounded-[10px] border border-[color:var(--paper-line)] bg-[color:var(--paper-surface)] px-4 py-3">
<div className="mb-2 flex items-baseline justify-between gap-3">
<h3 className="font-display text-[15px] font-medium tracking-[-0.01em] text-[color:var(--paper-ink)]">
Stat polygon
</h3>
<span
className="font-mono text-[10.5px] text-[color:var(--paper-ink-3)]"
title="Score is pass@1; cheap/light/fast/lean are cost, tokens, time, and steps relative to the best shown agent"
>
outer edge = best shown
</span>
</div>

<div ref={chartContainerRef} className="h-52 min-w-0">
{chartSize.width > 0 && chartSize.height > 0 ? (
<ResponsiveContainer
width={chartSize.width}
height={chartSize.height}
>
<RadarChart data={axisRows} cx="50%" cy="50%" outerRadius="76%">
<PolarGrid stroke="var(--paper-line-2)" />
<PolarAngleAxis
dataKey="axis"
tick={{
fontSize: 10,
fill: "var(--paper-ink-2)",
fontFamily: "var(--font-geist-mono), ui-monospace, monospace",
}}
/>
<PolarRadiusAxis domain={[0, 1]} tick={false} axisLine={false} />
<Tooltip
content={renderTooltip}
wrapperStyle={{ zIndex: 10, outline: "none" }}
/>
{visibleKeys.map((key) => {
const color = agentColorByKey[key] ?? AGENT_COLORS[0];
const isHovered = hoverAgent === key;
const isDimmed = hoverAgent != null && hoverAgent !== key;
return (
<Radar
key={key}
dataKey={key}
stroke={color}
strokeWidth={isHovered ? 2.6 : 1.8}
strokeOpacity={isDimmed ? 0.2 : 1}
fill={color}
fillOpacity={isHovered ? 0.22 : isDimmed ? 0.02 : 0.09}
dot={{ r: isHovered ? 3 : 2, fill: color, strokeWidth: 0 }}
isAnimationActive={false}
onMouseEnter={() => onHoverAgent?.(key)}
onMouseLeave={() => onHoverAgent?.(null)}
style={{ cursor: "pointer" }}
/>
);
})}
</RadarChart>
</ResponsiveContainer>
) : null}
</div>

<AgentLegend
items={agentSummaries.map((summary, idx) => ({
key: summary.key,
label: summary.label,
color:
agentColorByKey[summary.key] ??
AGENT_COLORS[idx % AGENT_COLORS.length],
queueKey: summary.queueKey,
model: summary.model,
agent: summary.agent,
}))}
hiddenKeys={hiddenAgents}
onToggle={onToggleAgent}
hoverKey={hoverAgent ?? null}
onHover={onHoverAgent}
/>
</div>
);
});
15 changes: 15 additions & 0 deletions frontend/src/components/experiment-trials-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ const TaskSolveHeatmap = dynamic(
},
);

const AgentStatRadar = dynamic(
() => import("./agent-stat-radar").then((mod) => mod.AgentStatRadar),
{
ssr: false,
},
);

export type AgentSummary = ExperimentAgentSummary;

type ExperimentTrialsTableProps = {
Expand Down Expand Up @@ -1826,6 +1833,14 @@ export function ExperimentTrialsTable({
hoverAgent={hoverAgent}
onHoverAgent={setHoverAgent}
/>
<AgentStatRadar
tasks={tasks}
agentSummaries={sortedAgentSummaries}
hiddenAgents={hiddenAgents}
onToggleAgent={toggleAgent}
hoverAgent={hoverAgent}
onHoverAgent={setHoverAgent}
/>
<TaskSolveHeatmap
tasks={tasks}
agentSummaries={sortedAgentSummaries}
Expand Down