Results browser¶
heams browse serves a local, read-only web GUI over saved results. It is for
reviewing runs, not a leaderboard: start from the campaign-wide criterion
table and drill down to the transcript and judge output of any sample.
Nothing is re-run and no API keys are needed.
The command prints a local URL (default http://127.0.0.1:8765/) and opens it
in your browser. Stop it with Ctrl+C. Flags are listed in the
CLI reference.
What it reads¶
Point --results-dir at a single run directory, a campaign snapshot, or the
whole results/ folder; it is searched recursively:
- Every directory with a
runs.jsonlis an experiment. Its benchmark, memory system, agent model andlimitcome fromexperiment_config.*.yaml; its start date from therun_idin that file name; its status fromrun_status.json(complete,failed,incompletewhen the file is missing, e.g. a job that was still running, orunknownwhen it cannot be read); its sample count from the records inruns.jsonl. - Scores are computed from
runs.jsonlitself, so runs never processed byheams generate-reportare included. When the run directory has asubmetrics.csv(written at run level byheams generate-report --detailed), each experiment's rows are also shown on its experiment page. - Directories whose name starts with
_(_rollup/, the browser's own cache) and agentworkspace/folders are ignored.
BEAM experiments share type: beam; the browser splits them by the config's
split (BEAM 100K, 500K, 1M, 10M).
Views¶
Every view and filter is encoded in the URL, so a link to a filtered table or a single failing sample can be pasted into a review thread for anyone browsing the same directory.
Criterion table¶
Memory systems as columns; rows grouped by dimension (Performance, Privacy, Efficiency), benchmark and criterion. Performance benchmarks are ordered by context size.
Results for one memory system and benchmark are often spread over several runs: smoke tests, subsets, retries, full campaigns, and runs still in progress. Each column therefore takes, per benchmark, one run, chosen by the Source run menu:
| Source run | Picks |
|---|---|
| Largest run (default) | The run with the most samples recorded in runs.jsonl, finished or not; the newest breaks ties |
| Latest finished run | The newest finished run, whatever its size |
| Latest finished full-dataset run | The newest finished run over its whole dataset: no limit and, for Retention Policy, no policy_ids selection |
A run is finished when its run_status.json reports success. Runs
that are still going, were cut off, or failed (incomplete, failed) only
count for "Largest", and their cells are flagged with that status. Their
numbers cover the samples recorded so far; use rescan to recount and
re-read runs that have grown.
Columns can therefore mix runs from different campaigns and dates. Every
cell shows its sample count and run date (n=500 · 09-14), and hovering shows
the run directory, start time, status, sample count and limit. The
collapsible Sources panel lists, for every memory system × benchmark, the
selected run (linked), its date, size and status, and how many other runs
exist. When no run matches, the cell shows the newest run's status instead.
Check the sources before comparing columns
"Largest" can select an unfinished run, a subset (limit: 75) or a run
of the same benchmark type made with a different dataset or evaluator
settings; "Latest" can select a smoke run (limit: 1). Use "Latest
finished full-dataset run" for whole-dataset numbers, and the Sources
panel to see where each column came from.
Cell values:
- Each judge record is assigned to a criterion: its
criteriontag if the judge sets one, else the first tag group the benchmark declares incriterion_tags(for example BEAMquestion_type, MPBenchattack_class, Erasability & Editabilitytask_type / phase); seeheams.reporting.criteria. A sample's criterion score is the mean of its records; the cell is the mean over samples, withnsamples. - The
Overallrow is the task accuracy: the meanaccuracy_scoreof the scored samples. - Samples without a valid score (
score_available: false, for example a judge or runtime failure) are left out of every value, not counted as 0, and reported asexcl.on theOverallrow. A run with no scored sample showsN/A. - Efficiency rows are per-sample means of duration, LLM tokens, memory operations and memory-operation error rate.
- The best value in each row is underlined (lowest for time, tokens and error rates). Percentages are heat-shaded.
Retention Policy runs over every policy (policy_ids: null) are table
sources like any other benchmark. Runs restricted to some policies (for
example a smoke run on RP-01, or older one-experiment-per-policy
campaigns) cannot stand for the benchmark, so they are listed under
Experiments only.
Click a cell to open the experiment behind it, filtered to that criterion.
The Experiments tab lists every experiment, newest first, with its date,
status, sample count and scope (full dataset, limit, or subset).
Experiment¶
Status, agent model, mean accuracy and fully successful samples, followed by:
- the headline aggregates from
submetrics.csv, the resolved experiment config andrun_status.json; - a pass-rate breakdown for every judge-record tag (question type, attack class, difficulty, …), each linking to the matching samples;
- the list of samples, searchable by scenario, question and tag, filterable by result (all correct, any failure, all wrong, unscored) and sortable by accuracy, failures, turns, duration and tokens.
Sample¶
One RunResult: accuracy and efficiency figures, the judge's audit notes,
every judge record (expected, observed, reason) with a pass/fail filter, the
transcript with each tool call's arguments and output, system and component
metrics, and the memory ledger. Prev / Next step through samples.
Recorded values are laid out for reading without changing what they say:
JSON or Python str() written as text (a tool output such as
['{"content": ...}']) is parsed and shown as indented key: value lines,
text stored escaped (a model answer as a Python repr inside a judge reason)
gets its line breaks back, and long cells expand in place on click. Timeline
rows preview an event's text values (content: ...) rather than its JSON.
Download raw JSON keeps the exact recorded form.
LaTeX between \(…\), \[…\], $$…$$ or $…$ is typeset as math.
The renderer (Temml, about 170 KB) is fetched only when
a page holds math, then cached by the browser, and each text block is
typeset as it scrolls into view, so pages without math pay nothing.
Expressions that do not parse stay as written; Typeset math on the sample
page switches back to the source (remembered per browser).
Benchmarks make this easier by putting the compared texts in a judge
record's expected and observed fields and keeping reason to the
verdict, for example extracted AIME answer '214', expected '204'.
Full-context runs can hold megabytes of recalled history per tool call, so
long strings, long lists and oversized tool or ledger payloads are shortened
for display. Load untruncated shows the full record, and Download raw
JSON saves the exact runs.jsonl line. Samples over 64 MB (some
EvoMemory records are about 2 GB) are only shown shortened; download them
raw to see everything.
Execution trace timeline¶
When a run wrote an execution_traces.jsonl sidecar (one trace per sample,
joined through RunResult.trace_id), the sample page shows that sample's
execution trace: every framework and agent action in the order it
happened.
- Summary cards: outcome, event count, wall time, LLM calls and tokens, tool calls, memory operations and errors, plus how often each tool was called.
- A waterfall with one row per event: start offset, duration, type, name, step, status, a bar on the sample's time axis, and a content preview (with the tool arguments). Rows are grouped by phase and user stimulus, colored by type (priming, manipulation, user stimulus, memory operation, LLM call, tool call), and failed events are highlighted.
- Filters by event type (click the chips), phase, stimulus, free text, and errors only. With a filter on, the bars zoom to the matching events' time window. Filters are kept in the URL.
- Click a row to open the event in full: timing and coordinates, token usage, error, content, arguments, result, and for LLM calls every input message sent to the model.
LLM-call events carry the whole prompt, so a trace is often much larger than its run record. The timeline reads only each event's small fields and previews; an event's full content is loaded when you open it. Samples without a trace, or from runs made before traces were recorded, say so in place of the timeline. Traces hold model-visible prompts, recalled memory and tool output; treat them as private run artifacts.
Where names, sections and order come from¶
The browser has no list of benchmarks or memory systems. Everything it shows about them is declared by the framework, so results of a new benchmark or a new memory plugin appear correctly without changing the browser:
| Shown | Declared by |
|---|---|
| Benchmark name, section (Performance or Privacy), order, criteria | The benchmark's report_info (BenchmarkInfo, see Adding a benchmark) |
| Separate rows per variant (BEAM splits) and their order | BenchmarkInfo.variant and variant_context_tokens |
| Which runs cover the whole dataset | BenchmarkInfo.scope |
| Memory system names | The plugin's MemoryPlugin.display_name; two configurations of one plugin keep their config ids apart |
| Trace event colors and counts | The event kinds in heams.execution_traces (TRACE_EVENT_KINDS) |
Results of a benchmark the installed version does not declare are still
listed, under their config type in an "Other" section. Memory systems whose
plugin is not installed are shown by their config id; install the plugin
(for example uv sync --extra all-memory-plugins) to get its display name.
Performance and caching¶
Each experiment's runs.jsonl is read once to record where every sample
starts plus a short summary of it (scores, tags, metrics). That takes about
20 s for a 3.6 GB file and is cached as JSON under
<results-dir>/_results_browser_cache/; the cache is rebuilt when
runs.jsonl changes size or modification time. Opening a sample then reads
only that one line. When the results directory is read-only, indexes are
kept in memory for the session instead.
The server starts indexing the runs the table needs as soon as it starts,
on a background thread, so the page is usable immediately: cells show
indexing and fill in as runs are processed (the page refreshes itself).
The default selection is indexed first, then the other Source run choices so
switching is quick. The first start on the whole results/ folder takes a
few minutes; later starts read the cache.
Use rescan on the table page after new runs land while the server is running.
Records over 64 MB are never loaded whole. The file is memory-mapped and
such records are walked in place: only the small fields a summary needs
(scores, metrics, the first question) are decoded, and the sample view is
built from the same walk. A 7.7 GB file of four EvoMemory records indexes in
about two minutes within a 4 GB Slurm job. Indexes are cached in
--cache-dir when given, in one subdirectory per results directory so
one cache can serve several; use it when the results directory is shared or
read-only so the browser does not write into it.
Viewing cluster results from your laptop¶
Campaign results usually stay on the cluster's shared file system, where they are too large to copy. The browser then runs on the cluster, as a small Slurm CPU job next to the data, and your laptop's browser reaches it through an SSH tunnel:
laptop browser ── http://localhost:8767
└─ SSH tunnel ── <login host> ── <compute node>:127.0.0.1:<port>
└─ heams browse (Slurm job)
└─ reads <results-dir>
The server listens on the compute node's loopback interface only, so it is reachable only through an SSH session to that node, which only you hold. All commands below run on your laptop; the ones that act on the cluster go through SSH.
Requirements:
- An SSH host entry for the cluster's login node in your
~/.ssh/config(called<cluster>below), with your cluster user name. - SSH access, through the login node, to compute nodes running your jobs (the usual Slurm setup). The tunnel ends on the node itself.
uvon the cluster, and a way for the cluster to clone this repository.
Quick start¶
python_package/cluster/browse_dashboard.sh does every step of the manual
procedure below.
First, enter your cluster's values in .env
The launcher reads its settings from the repository's .env, the same
git-ignored file that holds your API keys, so your cluster's host, paths
and partition never end up in the repository. .env_template lists the
BROWSE_* keys with empty values. If you don't have a .env yet, create
it from the template at the repository root; then fill in at least
BROWSE_CLUSTER_HOST and BROWSE_RESULTS_DIR:
If your .env predates these keys, copy the BROWSE_* block from
.env_template into it. The script reads only the BROWSE_* lines, as
literal values (never executed); empty values fall back to the defaults
below, and a variable exported in your shell takes precedence over the
file. From a linked worktree without its own .env, it uses the main
checkout's, like the heams CLI. Without the two required keys the script
stops and says which one is missing.
Then start the dashboard:
It opens one shared SSH connection (answer your cluster's login prompt, including any one-time password, once; the connection is reused for 12 hours), prepares a dedicated checkout on the cluster on first use, reuses the running dashboard job or submits one, waits for it, opens the tunnel and opens http://localhost:8767 in your browser.
| Command | Does |
|---|---|
browse_dashboard.sh or browse_dashboard.sh start |
Connect, start or reuse the job, tunnel, open the browser |
browse_dashboard.sh status |
Show the dashboard job and whether the tunnel is open |
browse_dashboard.sh stop |
Cancel the job and close the tunnel |
browse_dashboard.sh update |
Pull and re-sync the cluster checkout, and restart a running dashboard on it |
A fresh start takes about a minute, depending on the queue; running it again while the job is up reopens the tunnel in a few seconds.
.env key |
Meaning | Default |
|---|---|---|
BROWSE_CLUSTER_HOST |
SSH host of the login node, from your ~/.ssh/config |
required |
BROWSE_RESULTS_DIR |
Results directory on the cluster | required |
BROWSE_SLURM_PARTITION, BROWSE_SLURM_ACCOUNT |
Where the job runs | cluster defaults |
BROWSE_CPUS, BROWSE_MEM, BROWSE_TIME_LIMIT |
Job resources | 2, 8G, 08:00:00 |
BROWSE_LOCAL_PORT |
Port on your laptop | 8767 |
BROWSE_REMOTE_CHECKOUT, BROWSE_REMOTE_CACHE |
Dedicated checkout and index cache on the cluster | under $SCRATCH (or $HOME) |
BROWSE_REPO_URL, BROWSE_BRANCH |
What the first use clones | this repository's HTTPS URL, default branch |
Any of them can also be set for one run, for example
BROWSE_LOCAL_PORT=8780 BROWSE_MEM=16G python_package/cluster/browse_dashboard.sh;
NO_BROWSER=1 skips opening the browser.
Manual procedure¶
The same steps by hand, for when you need to adapt or debug one. Replace
<cluster>, <results-dir> and the other placeholders with your values.
1. Open one shared connection¶
Open a background "master" connection that the following commands reuse, so any login prompt (password, one-time code) is answered once:
mkdir -p ~/.cache/heams-browse
SOCKET=~/.cache/heams-browse/<cluster>.sock
ssh -M -S "$SOCKET" -o ControlPersist=12h -Nf <cluster>
ssh -S "$SOCKET" -O check <cluster>
Master running means you are connected.
2. Prepare a checkout on the cluster (once)¶
Use a checkout that no campaign job runs from: campaign checkouts must not
be synced while their jobs are pending or running. make setup is not
needed: the browser uses no memory plugins and no .env. The commands run
through bash -lc because tools such as uv are often only on a login
shell's PATH.
ssh -S "$SOCKET" <cluster> "bash -lc 'git clone https://github.com/milatechtransfer/heams.git \$SCRATCH/heams-browse && cd \$SCRATCH/heams-browse/python_package && uv sync --python 3.12'"
To update it later (the checkout is dedicated to the dashboard, so it can follow its branch even after the branch is rewritten):
ssh -S "$SOCKET" <cluster> "bash -lc 'cd \$SCRATCH/heams-browse && git fetch && git reset --hard @{upstream} && cd python_package && uv sync --python 3.12'"
3. Start the server job¶
The job reads the results directory and keeps its index cache in your own
space (--cache-dir), so it never writes into a shared folder. --port 0
picks a free port on the node. Add --partition or --account if your
cluster needs them.
ssh -S "$SOCKET" <cluster> "bash -lc 'mkdir -p \$SCRATCH/heams-browse/logs && cd \$SCRATCH/heams-browse/python_package && sbatch --parsable --job-name=heams-browse --cpus-per-task=2 --mem=8G --time=08:00:00 --output=\$SCRATCH/heams-browse/logs/browse-%j.out --wrap=\"exec .venv/bin/heams browse --results-dir <results-dir> --cache-dir \$SCRATCH/heams-browse-cache --no-open --port 0\"'"
JOB=<job id printed above>
4. Find the node and port¶
Once the job is RUNNING, its log shows the address:
ssh -S "$SOCKET" <cluster> "bash -lc 'squeue -j $JOB -h -o \"%T %N\"; cat \$SCRATCH/heams-browse/logs/browse-$JOB.out'"
RUNNING <node>
Browsing <n> experiments under <results-dir>
Open http://127.0.0.1:<port>/ (Ctrl+C to stop)
5. Open the tunnel¶
Forward a local port to the server on the node, hopping through the login host over the shared connection:
ssh -f -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=60 \
-o "ProxyCommand=ssh -S $SOCKET -W %h:%p <cluster>" \
-L 127.0.0.1:8767:127.0.0.1:<port> -l <cluster user> <node>
Then open http://localhost:8767. On the first start the table shows
indexing cells that fill in by themselves; later jobs reuse the cache.
6. Stop everything¶
ssh -S "$SOCKET" <cluster> "bash -lc 'scancel $JOB'"
pkill -f "127.0.0.1:8767:127.0.0.1:"
ssh -S "$SOCKET" -O exit <cluster>
The job also ends at its time limit, and the tunnel closes with the job or the shared connection.
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
Missing setting(s): BROWSE_CLUSTER_HOST BROWSE_RESULTS_DIR |
Add those keys to .env (see the BROWSE_* block of .env_template), or export them. |
Could not connect to <cluster> |
Check that ssh <cluster> works on its own; the message includes SSH's error. |
Could not open the tunnel on port 8767: ... Address already in use |
Another program uses that local port. Close it, or set another BROWSE_LOCAL_PORT. |
The tunnel fails with Permission denied |
Your cluster does not allow SSH to compute nodes, or only to nodes running your jobs: check that the job is RUNNING. |
uv: command not found |
The command ran outside a login shell. Wrap it in bash -lc '...' as above. |
| Cloning fails on the cluster | The cluster cannot reach GitHub over HTTPS, or uses an SSH key under a host alias: set BROWSE_REPO_URL. |
The job stays PENDING |
The partition is busy. Wait, or set another CPU BROWSE_SLURM_PARTITION. |
The job state is OUT_OF_MEMORY |
Set BROWSE_MEM=16G. Records of several GB are read in place, but mapped file pages still count towards the job's memory. |
Cells stay indexing for minutes |
First start on a large folder: big runs.jsonl files are read once over the network file system, then cached. |
| New runs don't appear | Click rescan on the table page. |
Development¶
The server is heams.results_browser:
| Module | Role |
|---|---|
catalog.py |
Finds experiments, builds and caches runs.jsonl indexes, serves bounded run records |
records.py |
Reads records in place over a memory map (large records are never loaded whole) |
table.py |
Chooses source runs and computes criterion-table cells |
traces.py |
Indexes execution_traces.jsonl and builds timeline rows |
server.py |
HTTP server: JSON API under /api/, the page and its assets under /static/ |
Criterion rules live in heams.reporting.criteria, shared with other
criterion-level reports.
The page is plain HTML, CSS and JavaScript modules in
heams/results_browser/static/, with no build step:
| File | Role |
|---|---|
js/dom.js |
html tagged template that escapes every interpolated value |
js/format.js, js/filters.js, js/router.js |
Pure logic: labels and formats, sample and event filters, URL state |
js/readable.js |
Pure logic: decodes escaped text, JSON and Python literals, and lays values out for reading |
js/math.js |
Finds LaTeX in text blocks and typesets it lazily with Temml |
vendor/temml-<version>/ |
Temml, unmodified from its npm release (MIT); excluded from pre-commit hooks and cached by browsers |
js/api.js, js/ui.js |
API client and shared page components |
js/views/*.js |
Criterion table, experiment list, experiment, sample, trace timeline |
The server sends a Content-Security-Policy that allows only the page's own
scripts and styles, so markup may not contain inline scripts or style
attributes; dynamic sizes and colors are set through data attributes (see
mount in dom.js).
Tests are in tests/results_browser/. The JavaScript unit tests
(tests/results_browser/frontend/*.test.mjs) use Node's built-in runner and
run as part of pytest when node is installed:
Access¶
The server binds to 127.0.0.1 by default, so only your machine can reach it.
--host 0.0.0.0 exposes it, and the raw results with it, to your network; on
the cluster use the SSH tunnel described in
Viewing cluster results from your laptop.