{% from 'runs/_runtime.html' import what_ran %} {% extends "base.html" %} {% block title %}{{ trial.task_base }} — Nullius{% endblock %} {% block crumb %}/ {{ job.job_id }} / {{ trial.task_base }}{% endblock %} {# The chart grid and the label chips are shared with the compare page's sides, so there is one definition of each and the two cannot drift apart. They take `view_url` below as their link builder. #} {% import "traces/_charts.html" as ui %} {# Every filter control is a link to this same page with a different `hide` set, so a filtered view has a URL — shareable, bookmarkable, and exactly what save-trace writes out. `viewing` rides along so toggling a chip while reading a saved view does not silently drop back to the producer's capture. `cf` is the charts' own legend state, which is a different query key and a different thing: it changes what one chart draws, never what the trace contains. It defaults to the current one so a control that only means to change `hide` carries the legend through untouched, and vice versa. #} {% macro view_url(f, cf=none) -%} {{ url_for('trial', job_id=job.job_id, trial_name=trial.trial_name, trace=viewing or none, tab='trace', **view_query(f, cf if cf is not none else chart_filter)) }} {%- endmacro %} {% from 'traces/_judgement.html' import judgement_panel %} {% block body %}

{{ trial.task_base }}

{{ trial.outcome_label }} {# The recorded exception type, beside the label that came from it. For an Unknown it says which class the classifier has no side for, so a reader can place it (classify._AGENT_EXCEPTIONS / _INFRA_EXCEPTIONS) instead of guessing from the colour. #} {% if trial.exception_type %}{{ trial.exception_type }}{% endif %}
{# The trial records a bare model name ("gpt-5.6-luna"); the job keeps the provider-qualified one ("openai/gpt-5.6-luna"). Prefer the fuller id. #} {% set model = job.model_name if job.model_name and job.model_name.endswith(trial.model_name or '~') else (trial.model_name or job.model_name) %}

{{ model }}· {{ trial.source|dsname }} {%- if trial.agent_version %}·v{{ trial.agent_version }}{% endif %} {%- if job.runner %}·{{ what_ran(job) }}{% endif %}

{# The check cards speak the dataset's language: labels come from its Vocabulary, and a category it doesn't have (a verifier with no regression guard and no patches; a session-derived trial has neither) is not drawn at all rather than dashed under a foreign name. The dash inside a drawn card still means "this trial reported nothing". #} {% set vocab = trial.vocab %} {% if vocab.gained %}
{{ vocab.gained }}
{% if trial.f2p_pass or trial.f2p_fail %}{{ trial.f2p_pass }}/{{ trial.f2p_fail }}{% else %}—{% endif %}
{% endif %} {% if vocab.held %}
{{ vocab.held }}
{% if trial.p2p_pass or trial.p2p_fail %}{{ trial.p2p_pass }}/{{ trial.p2p_fail }}{% else %}—{% endif %}
{% endif %} {% if vocab.has_patch %}
Patch applied
{% if trial.patch_exists is none %}—{% else %}{{ 'Yes' if trial.patch_applied else 'No' }}{% endif %}
{% endif %} {# The verifier's numeric score. Mostly redundant with the outcome tag on a pass/fail verifier (0 or 1), but it's the number Harbor's mean is built from, and partial-credit verifiers make it a real scalar. #}
Reward
{{ trial.reward if trial.reward is not none else '—' }}
Agent time
{{ trial.dur_agent|secs }}
{% if traj and not traj.error %}
Steps
{{ traj.n_steps }}
Tool calls
{{ traj.n_tool_calls }}
{% endif %}
Trajectory · {{ 'recorded' if trial.has_trajectory else 'absent' }} Trace · {{ 'recorded' if trial.has_trace else 'absent' }} Reward · {{ 'recorded' if trial.reward is not none else 'absent' }} {# Rate limits the agent's SDK retried and the run survived. They are in the gateway log and nowhere else — result.json records only the 429 that killed a run — so a trial without that log reads as unmeasured, not clean. #} {% if trial.n_rate_limited_retries is none %} Rate limits · not recorded {% elif trial.n_rate_limited_retries %} {{ trial.n_rate_limited_retries }} rate-limited call{{ '' if trial.n_rate_limited_retries == 1 else 's' }} retried {% else %} Rate limits · none logged {% endif %}
{% if trial.is_regression %}

⚠ This patch broke {{ trial.p2p_fail }} previously-passing test{{ '' if trial.p2p_fail == 1 else 's' }}

These passed before the patch was applied and fail after it — a regression, not just a missed fix.

{% endif %} {% if trial.f2p_failures %}

Tests that had to pass, still failing

{% endif %} {% if trial.exception_type %}

{{ trial.exception_type }}

{{ trial.exception_message }}
{% endif %}

Shape of the run

{# The phase bar reads only trial.*, so it renders whether or not a trajectory was recorded: a trial that failed before writing one still shows its timing. #} {% set total = trial.dur_phases + trial.dur_other %} {% if total %}

Where the time went

{# All four phases Harbor records. Leaving agent_setup out understated the bar by a third on some agents, and it is the most actionable number in the set — installing the agent can cost more than solving the task. #}
{# A 0s phase emits no segment at all, so a control's empty setup leaves no stray sliver. #} {% if trial.dur_env|phase %}{% endif %} {% if trial.dur_agent_setup|phase %}{% endif %} {% if trial.dur_agent|phase %}{% endif %} {% if trial.dur_verifier|phase %}{% endif %} {% set items = job.runner_items[1] if job.runner_items else 'items' %} {% set other_is = 'the wait between the answer and its judgement: the judge reads the answers once its share of the ' ~ items ~ ' is answered' if job.runner else 'time Harbor does not attribute to a phase: image pull, container start and teardown, artifact collection' %} {% if trial.dur_other|phase %}{% endif %}
Env {{ trial.dur_env|secs }} Setup {{ trial.dur_agent_setup|secs }} Agent {{ trial.dur_agent|secs }} Verify {{ trial.dur_verifier|secs }} Other {{ trial.dur_other|secs }}

The bar spans the trial's full {{ trial.dur_total|secs }}. {% if job.runner %}“Other” is the wait for the judge, which reads the answers once its share of the {{ items }} is answered.{% else %}“Other” is harness overhead Harbor doesn't break out — the same tax for every agent.{% endif %} {% if trace and trace.has_spans %}The Trace tab shows recorded span timing and its measured or inferred provenance.{% elif traj and traj.has_step_clock %}Conversation reports time between source steps; these are not per-tool durations.{% else %}No per-step clock is available. Conversation preserves the recorded order.{% endif %}

{% endif %} {% if traj and traj.error %}

Trajectory unreadable

{{ traj.error }}
{% elif traj %}
{% set has_growth = traj.token_series|length > 1 %} {% if has_growth %}

Prompt growth

{% set max_sent = traj.token_series|map(attribute='sent')|max %}
{% for p in traj.token_series %} {% set h = (56 * p.sent / max_sent)|round(1) if max_sent else 0 %} {% set ch = (56 * p.cached / max_sent)|round(1) if max_sent else 0 %} {% if ch >= 1 %}{% endif %} {% endfor %}
new tokens served from cache {% if traj.cache and traj.cache.hit is not none %} cache hit {{ traj.cache.hit|hitrate }} {% if traj.cache.reported_diverges %} reported {{ traj.cache.reported_hit|hitrate }} {% endif %} {% endif %} {% if traj.total_reasoning_tokens is not none %} reasoning {{ traj.total_reasoning_tokens|tokens('control') }} {% endif %}

One column per LLM step, height ∝ input tokens; the gray band is what came from cache. {%- if traj.cache and traj.cache.convention == 'anthropic' %} Anthropic-style usage: cache writes are added back onto the step that paid for them.{%- endif %}

{% elif traj.token_series or traj.total_reasoning_tokens is not none %} {# one call: no growth to draw, so the call itself - what came from cache, what was new, what the model wrote #} {% set p = traj.token_series[0] if traj.token_series else none %}

Usage

{% if p and p.sent %} {% endif %}
{% if p %}served from cache {{ p.cached|tokens('control') }} new input {{ p.new|tokens('control') }} output {{ p.completion|tokens('control') }} {% if p.sent %}cache hit {{ (p.cached / p.sent)|hitrate }}{% endif %}{% endif %} {% if traj.total_reasoning_tokens is not none %}reasoning {{ traj.total_reasoning_tokens|tokens('control') }}{% endif %}

{% if job.runner %}{{ job.runner }} asks its model once for each of its {{ job.runner_items[1] if job.runner_items else 'items' }}, so the bar is that one call: its input, from cache or new, and what it wrote. An agent's run charts its calls one after another instead, as its context grows.{% else %}One step carried usage: the bar is that call; the growth strip needs two.{% endif %}

{% endif %} {% if traj.calls %} Tool calls
{% for c in traj.calls %} {% endfor %}
{# Tool summary. Grouped by category, never by the agent's own tool names: OpenCode writes `bash`, Claude Code `Bash`, another agent a sentence, so a count by name compares nothing across agents. The names stay beside each category as the evidence of what it folded — in the chip's hover and in the table below — so the fold can be checked. #}
{% for cat, n in traj.category_freq.most_common() %} {{ cat }} {{ n }} {% endfor %} {% if traj.n_failed_calls %}failed {{ traj.n_failed_calls }}{% endif %}
{% if traj.name_freq %} {# Each tool once, under the server that offers it. An agent's spellings of a name are its hover: evidence, never a comparison key. #} {% for t in traj.tools %} {% if t.server and (loop.first or traj.tools[loop.index0 - 1].server != t.server) %}{% endif %} {% endfor %}
ToolCategoryCallsFailedTime
{{ t.server }}
{{ t.name[:80] }}{% if t.name|length > 80 %}…{% endif %} {{ t.category }} {{ t.calls }} {{ t.failed or '—' }} {{ t.ns|nsdur if t.ns is not none else '—' }}
{% endif %} {% endif %}
{% if loc %}

Against the gold patch

The gold patch is a correct answer, not the only one.

{% endif %} {% if traj.files_touched %}

Files the agent edited

{% endif %} {% else %}

No trajectory recorded for this trial.

{% endif %}
{% if job.runner and bench_parts %} {#- scored part by part by the benchmark's own script: the score is the mean of these, each as the script wrote it #}

{{ job.runner }}'s score gives the outcome

{% if bench_parts.says %}{{ bench_parts.says }}. {% endif %}{% if trial.score is not none %}This one scored {{ '%.3f'|format(trial.score) }}: the mean over its {{ bench_parts.parts|length }} {{ bench_parts.nouns }}, one without a score counting nothing.{% endif %}

Its {{ bench_parts.parts|length }} {{ bench_parts.nouns }} {% for k in bench_parts.shows %}{% endfor %} {% for x in bench_parts.parts %} {%- for k in bench_parts.shows %}{% endfor -%} {% endfor %}
#AskedAnswered{{ k|capitalize }}Score
{{ x.n }}{{ x.text }}{% if x.said is not none %}{{ x.said }}{% else %}no answer{% endif %}{% if x.shows and x.shows[k] is not none %}{{ x.shows[k] }}{% endif %}{% if x.score is not none %}{{ '%.3f'|format(x.score) }}{% else %}—{% endif %}

Below, Nullius's own trajectory judge: experimental, asked on a click.

{% elif job.runner %}

{{ job.runner }}'s judge gives the outcome

{% if bench_judge %}

{{ bench_judge.judge or job.runner_judge }}{% if bench_judge.model %} · sent as {{ bench_judge.model }}{% endif %}

Said {{ (bench_judge.said or '—')[:200] }}: {{ 'resolved' if trial.outcome == 'resolved' else trial.outcome|replace('_', ' ') }}.

What it was asked
{{ bench_judge.asked }}
{% else %}

{% if job.runner_judge %}{{ job.runner_judge }}. {% endif %}No record of its call for this one{% if trial.outcome not in ('resolved', 'failed_verification') %}: it never got to judge it{% endif %}.

{% endif %}

Below, Nullius's own trajectory judge: experimental, asked on a click.

{% endif %} {{ judgement_panel(judgement, job, trial, csrf) }}
{#- the second judgement: the same questions, asked of the LLM judge Settings names, its records apart -#}
{{ judgement_panel(llm_judgement, job, trial, csrf) }}
{# The task's instruction, scored on its own text: once per dataset and task, the same for every attempt, nothing of any run read. An unclear instruction caps every agent on this task - a finding about the benchmark, not this trial. The score changes no outcome. #} {% set tt = task_tab(job, trial, traj) %} {% set tq = tt.quality %}

Task instruction experimental

How clearly the task says what is wanted, judged from its text alone: once per dataset and task, shared by every trial of it. An unclear instruction caps every agent's score on this task, a finding about the benchmark, not about this agent. It changes no outcome.

{% if job.is_control %}

A control run: its trials are a check on the tasks, so the instruction is scored from an agent's run of the same dataset.

{% endif %}
Instruction quality
{% if tq %}
{{ '%.1f'|format(tq.score) }}/ {{ tq.top }}
{{ tq.label }}
{% if tq.confidence is not none %}Confidence{{ '%.0f'|format(tq.confidence * 100) }}%{% endif %}{{ tq.model }} · asked {{ tq.asked_at[:16]|replace('T', ' ') }}Z
{% else %}
not scored
Score instructions on the run page asks the judge about each task's text once.
{% endif %}

Rubric

    {% for level in tt.rubric %}
  1. {{ loop.index0 }} {{ level }}
  2. {% endfor %}

The instruction, as the agent received it

{% if tt.instruction %}
{{ tt.instruction }}
{% else %}

No instruction can be read from this trial's trajectory.

{% endif %}
{% if trace and trace.error %}

Trace unreadable

{{ trace.error }}
{# `visible`, not `has_spans`: a filter may legitimately hide every span, and the panel that vanished would take the control to undo it with. #} {% elif trace and trace.visible %} {% set filt = trace.filter %} {# A trace whose producer measured nothing — a session transcript reconstructed from record arrival times — must not have its totals captioned as measured anywhere below: the banner says so once, and this word says so wherever the wall clock is named. #} {% set clocked = 'inferred' if trace.zero_measured else 'measured' %} {% if trace.zero_measured %}

⚠ Nothing was measured

All {{ trace.totals.span_count }} spans are inferred — the exporter captured nothing and the producer fell back to reading the agent's own logs. Every duration below is approximate, and simply wrong wherever tool calls ran in parallel.

{% endif %}

Filter

{% if viewing %}

Rendering {{ viewing }} — a view saved from this trial, not the producer's capture. Back to trace.json

{% endif %} {{ ui.facet_chips(trace.facets, filt, chart_filter, view_url) }} {% if trace.hidden_spans %} {# Hidden one at a time rather than by label — these need their own controls, since no chip above represents them. #}
{% for sid, label in trace.hidden_spans %} {{ label }} {{ sid[:12] }} {% endfor %}
{% endif %}

{% if filt.active %} {# `shown` counts the filtered view whole — a zoom narrows the waterfall, not the filter, so this sentence must not shrink with it. #} {{ trace.shown }} span{{ '' if trace.shown == 1 else 's' }} shown, {{ trace.hidden_count }} left out. Clear × {% else %} Click a label to leave out every span carrying it, or the × on a waterfall row — alt-click its bar — to leave out one call. Either way what it called goes with it, and the breakdown is recomputed without it. The producer's own totals stay as reported. {% endif %}

{% if filt.active %}
{# The view is reproduced from these, not from a session — the file written is the file this page is showing. #} {% for name in filt.names %}{% endfor %} {% for sid in filt.span_ids %}{% endfor %} {% if filt.zoom %}{% endif %} {% if viewing %}{% endif %}

Writes a new harbor-trace-v1 file beside the trial's own, in agent/: the hidden spans gone, totals recomputed over what is left, and the producer's numbers kept under filtered.source_totals. Nothing is overwritten — least of all trace.json, which is a capture that cannot be taken again. {% if trace.zoomed %}The zoom is not written: it is a way of looking at the file, and the link carries it.{% endif %}

{% endif %}
{# ---- distributions ---- The population of calls, not the timeline: how long a typical call runs and where the summed time sits. Deliberately above the waterfall, since it is the summary you read before scrolling one. It draws from `trace.roots`, which is already pruned by the same query string the page rendered from. The same macro draws each side of the compare page. Only the closing sentence differs, because only this page has a Time breakdown strip to point at — a page without one must not claim it. #} {% if charts %} {% set breakdown_tail %}The waterfall below is the timeline. Time breakdown is the accounting of where the seconds went; this is the shape of the calls that spent them.{% endset %} {{ ui.charts_panel(charts, chart_filter, filt, view_url, tail=breakdown_tail) }} {% endif %}

Time breakdown · not a timeline{% if trace.zero_measured %} · inferred, not measured{% endif %}

{% if trace.totals.segments %}
{% for s in trace.totals.segments %} {% endfor %}
{# Shares are of the wall clock, not of the segments' own sum, so categories that don't close read as short of 100% instead of being rescaled to fill it. #}
{% for s in trace.totals.segments %} {{ s.label }} {{ s.ns|nsdur }} {{ s.pct|share }} {% endfor %}

Categories, not a sequence. Reported by the trace, never summed from the bars below: spans nest.{% if trace.totals.unaccounted_ns %} Unaccounted is wall clock outside the instrumented spans.{% endif %}

{% else %}

The trace reports no totals.

{% endif %} {% if filt.active %}

Without the {{ trace.hidden_count }} span{{ '' if trace.hidden_count == 1 else 's' }} left out · recomputed here

{# Each Δ is against the same partition run over the unfiltered tree, so it is the filter's effect alone — not the gap between this arithmetic and the producer's, which is a different quantity and can be much larger. #} {% set changed = trace.view.compared_to(trace.baseline) %} {% if changed %}
{% for s in changed %}{% if s.ns %} {% endif %}{% endfor %}
{% for s in changed %} {{ s.label }} {{ s.ns|nsdur }} {{ s.pct|share }} {% if s.delta_ns %}{{ s.delta_ns|nsdelta }}{% endif %} {% endfor %}

Derived from the {{ trace.view.span_count }} remaining span{{ '' if trace.view.span_count == 1 else 's' }}. Each Δ is measured against this same arithmetic over the whole trace, so it is what the filter took out — read the absolute numbers against the strip above instead, which is the producer's own accounting and can differ from this one by a lot: time no span covers is “Unaccounted” here, where a producer may know it was a wait for a prompt and call it idle. Every instant is charged to exactly one category (waiting on a human beats real work, which beats the call it ran inside), because summing spans that nest or overlap attributes more than the run. So hiding a span moves its time into “Unaccounted” rather than out of the total: the run took just as long.

{% else %}

Nothing is left to break down.

{% endif %} {% endif %} {% if trace.coverage_pct %} {% set uncovered = trace.totals.wall_ns - trace.instrumented_ns %}

Coverage

{% if uncovered > 0 %} {% endif %}
{# `wall_ns` is the run the trace itself measures — the agent's own window, not the trial total, which also carries image pull, setup and the verifier. #}

The waterfall covers {{ trace.instrumented_ns|nsdur }} of the {{ trace.totals.wall_ns|nsdur }} the trace {{ 'infers' if trace.zero_measured else 'measures' }} — {{ '%.1f'|format(trace.coverage_pct) }}%. The axis is the captured window, not the run.

{% endif %} {% if trace.has_spans %}

Waterfall

{# ---- zoom ---- A second, explicit window on the axis (`?zoom=`), for the trace where one global axis over an hour makes a 20ms Read a hairline. It is not the filter and not a re-scaling of it: the rows are the zoomed span's subtree alone, placed on that span's own extent, and everything outside it is left out rather than squeezed in. The strips above are untouched — the totals and the breakdown are over the whole filtered view, the coverage strip describes the capture. Offsets on the axis and in the rows count from the zoomed span's start; the chip says where that is in the capture. #} {% if trace.zoomed %}

Zoomed to {{ trace.zoomed.label }} {{ trace.zoomed.span_id[:12] }} {{ trace.rows|length }} of {{ trace.shown }} span{{ '' if trace.shown == 1 else 's' }} · starts {{ trace.zoom_offset_ns|nsoff }} into the capture · {{ trace.axis.span_ns|nsdur }} wide — offsets below count from its start Reset zoom ×

{% elif trace.zoom_lost %}

No span {{ trace.zoom_lost[:24] }} in this view — nothing to zoom to, so this is the whole trace. It may be hidden by the filter, or belong to another trace file. Clear ×

{% endif %}
{{ trace.rows|length }} span{{ '' if trace.rows|length == 1 else 's' }}{% if trace.run_id %} · {{ trace.run_id }}{% endif %} · alt-click a bar to leave it out · shift-click, or ⤢, to zoom the axis to it
Span
{% for t in trace.ticks %}{{ t.ns|nsoff }}{% endfor %}
Duration
{# One global axis: every bar is positioned against the whole trace, so a child that starts before its parent renders early instead of being clamped into a tidy lie. Zoomed, the axis is the one span's extent and the rows are its subtree — still one axis for every row shown. #}
{% for r in trace.rows %}
{% if r.has_children %}{% else %}{% endif %} {# Links, not buttons: the filter and the zoom live in the URL, so these work with middle-click and keyboard navigation and need no script. #} {% if r.has_children and r.span.span_id != filt.zoom %} ⤢ {% endif %} ×
{{ r.span.duration_ns|nsdur }}
{% endfor %}
{% if trace.truncated %}

{{ trace.truncated }} further span{{ '' if trace.truncated == 1 else 's' }} not shown — the trace exceeds the render cap.

{% endif %}
{% for k in trace.kinds_present %}{{ k }}{% endfor %}
{# Said once, where the kind first appears. A reader who does not know what the suffix means reads the wrapper and the call inside it as two calls, which is the misreading the re-kinding exists to stop. #} {% if 'group' in trace.kinds_present %}

A group only brackets what is inside it — claude_code.tool holds the wait for permission and the execution, and a request whose whole response was one block holds that block — so it is charged no time of its own and the row below it is the same measurement. Hiding a group takes its contents with it; hiding the contents leaves the bracket.

{% endif %}
{% for q in trace.qualities_present %}{{ q }}{% endfor %}
{% else %}

Waterfall

Every span is filtered out — nothing left to draw.

{% endif %}
{# Content is fetched for the selected normalised span only. A large trace must not inline every prompt/result into hidden DOM (A15). #} {% if trace.has_spans %}

Select a span to load its recorded detail.

{% endif %} {% endif %} {% if not trace %}

No trace recorded

This trial has no trace sidecar. Its recorded trajectory, when available, is in Conversation. Missing span clocks are not reconstructed here.

{% endif %} {% if trace and not trace.error and not trace.visible %}

No spans recorded

The trace file contains no recorded spans to display.

{% endif %}
{% include 'traces/_conversation.html' %}

Artifacts

Read the recorded source files. Content is redacted and paged; source files remain intact.

{% for artifact in artifacts %} {% endfor %}
RecordAvailabilitySize
{{ artifact.label }}{{ 'Recorded' if artifact.exists else 'Not recorded' }}{{ ((artifact.size / 1024)|round(1)) ~ ' KB' if artifact.exists else '—' }}{% if artifact.exists %}Read →{% endif %}
{% endblock %}