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

- `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
12 changes: 12 additions & 0 deletions frontend/src/components/experiment-trials-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ const CostParetoGraph = dynamic(
},
);

const TaskSolveHeatmap = dynamic(
() => import("./task-solve-heatmap").then((mod) => mod.TaskSolveHeatmap),
{
ssr: false,
},
);

export type AgentSummary = ExperimentAgentSummary;

type ExperimentTrialsTableProps = {
Expand Down Expand Up @@ -1819,6 +1826,11 @@ export function ExperimentTrialsTable({
hoverAgent={hoverAgent}
onHoverAgent={setHoverAgent}
/>
<TaskSolveHeatmap
tasks={tasks}
agentSummaries={sortedAgentSummaries}
hiddenAgents={hiddenAgents}
/>
</div>
) : null}

Expand Down
178 changes: 178 additions & 0 deletions frontend/src/components/task-solve-heatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import { memo, useMemo } from "react";
import type { Task } from "@/lib/types";
import {
getExperimentAgentKey,
getModelScopedAgentsFromSummaries,
type ExperimentAgentSummary,
} from "@/lib/experiment-agent-grouping";
import { QueueKeyIcon } from "./queue-key-icon";

interface TaskSolveHeatmapProps {
tasks: Task[];
agentSummaries: ExperimentAgentSummary[];
hiddenAgents: Set<string>;
}

type Cell = { n: number; c: number } | null;

type HeatmapRow = {
taskId: string;
taskName: string;
cells: Cell[];
/** Mean solve rate over the agents that ran the task (the sort key). */
mean: number;
};

// Cell fill uses the dashboard's semantic status tokens: a pass ramp whose
// strength is the solve rate, a fixed fail tint for ran-and-never-passed,
// and near-nothing for never-ran.
function cellBackground(cell: Cell): string {
if (cell == null)
return "color-mix(in oklch, var(--paper-ink-4) 12%, transparent)";
if (cell.c === 0)
return "color-mix(in oklch, var(--paper-fail) 28%, transparent)";
const strength = Math.round(20 + 80 * (cell.c / cell.n));
return `color-mix(in oklch, var(--paper-pass) ${strength}%, transparent)`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In-progress trials painted as failed

Medium Severity

Every trial increments n, and any cell with c === 0 gets the fail tint meant for ran-and-never-passed. Pending, queued, and running trials have a null reward, so they land in that fail bucket and also pull the hardest-first sort upward. Live experiments then read unfinished work as hard failures, unlike the main trial matrix which keeps those statuses separate.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 68501eb. Configure here.

}

export const TaskSolveHeatmap = memo(function TaskSolveHeatmap({
tasks,
agentSummaries,
hiddenAgents,
}: TaskSolveHeatmapProps) {
const visibleSummaries = useMemo(
() => agentSummaries.filter((summary) => !hiddenAgents.has(summary.key)),
[agentSummaries, hiddenAgents]
);

const rows = useMemo(() => {
const modelScopedAgents = getModelScopedAgentsFromSummaries(agentSummaries);
const built: HeatmapRow[] = [];
for (const task of tasks) {
const byAgent = new Map<string, { n: number; c: number }>();
for (const trial of task.trials ?? []) {
const key = getExperimentAgentKey(trial, modelScopedAgents);
const bucket = byAgent.get(key) ?? { n: 0, c: 0 };
bucket.n += 1;
if (trial.reward === 1) bucket.c += 1;
byAgent.set(key, bucket);
}
const cells = visibleSummaries.map(
(summary) => byAgent.get(summary.key) ?? null
);
const ran = cells.filter((cell): cell is { n: number; c: number } =>
Boolean(cell)
);
if (ran.length === 0) continue;
built.push({
taskId: task.id,
taskName: task.name,
cells,
mean: ran.reduce((acc, cell) => acc + cell.c / cell.n, 0) / ran.length,
});
}
// Hardest first: the unsolved rows are the ones worth reading.
return built.sort((a, b) => a.mean - b.mean);
}, [tasks, agentSummaries, visibleSummaries]);

if (rows.length === 0 || visibleSummaries.length === 0) {
return null;
}

const gridTemplateColumns = `minmax(120px,220px) repeat(${visibleSummaries.length}, minmax(22px,1fr))`;

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)]">
Solve grid
</h3>
<span className="font-mono text-[10.5px] text-[color:var(--paper-ink-3)]">
per-task pass rate · hardest first
</span>
</div>

<div className="max-h-64 min-w-0 overflow-auto">
<div
className="sticky top-0 z-10 grid items-center gap-px bg-[color:var(--paper-surface)] pb-1"
style={{ gridTemplateColumns }}
>
<span />
{visibleSummaries.map((summary) => (
<span
key={summary.key}
title={summary.label}
className="flex justify-center"
>
<QueueKeyIcon
queueKey={summary.queueKey}
model={summary.model}
agent={summary.agent}
size={13}
/>
</span>
))}
</div>
{rows.map((row) => (
<div
key={row.taskId}
className="grid items-center gap-px"
style={{ gridTemplateColumns }}
>
<span
title={row.taskName}
className="truncate pr-2 font-mono text-[10.5px] leading-[18px] text-[color:var(--paper-ink-2)]"
>
{row.taskName}
</span>
{row.cells.map((cell, i) => (
<span
key={visibleSummaries[i].key}
title={
cell
? `${row.taskName} · ${visibleSummaries[i].label}: ${cell.c}/${cell.n} passed`
: `${row.taskName} · ${visibleSummaries[i].label}: not run`
}
className="h-[14px] rounded-[3px]"
style={{ background: cellBackground(cell) }}
/>
))}
</div>
))}
</div>

<div className="mt-2 flex items-center gap-3 font-mono text-[9.5px] text-[color:var(--paper-ink-3)]">
<span className="inline-flex items-center gap-1.5">
<i
className="inline-block h-2 w-4 rounded-[2px]"
style={{ background: cellBackground({ n: 1, c: 1 }) }}
/>
all pass
</span>
<span className="inline-flex items-center gap-1.5">
<i
className="inline-block h-2 w-4 rounded-[2px]"
style={{ background: cellBackground({ n: 2, c: 1 }) }}
/>
some
</span>
<span className="inline-flex items-center gap-1.5">
<i
className="inline-block h-2 w-4 rounded-[2px]"
style={{ background: cellBackground({ n: 1, c: 0 }) }}
/>
none
</span>
<span className="inline-flex items-center gap-1.5">
<i
className="inline-block h-2 w-4 rounded-[2px]"
style={{ background: cellBackground(null) }}
/>
not run
</span>
</div>
</div>
);
});