Exporting runs¶
Two exports, both covering any number of runs:
- CSV tables — a flat data frame for R, Pandas, or a journal's supplementary materials.
- Raw run folders — one zip holding the selected runs' directories, for handing to a coding agent or archiving.
Available from the Export button on the runs page, from glossogen export,
and from three REST endpoints. To chart the same numbers instead of exporting
them, see analysis and dashboards. To see the tables read in
pandas against a real cohort, see
the export notebook.
The tables¶
| Table | One row per | In the default set |
|---|---|---|
run_level.csv |
run | yes |
round_level.csv |
run and round | yes |
agent_level.csv |
run and agent | yes |
message_level.csv |
message | no, reads event logs |
round_context.csv |
run and round, one column per agent's briefing | no, reads event logs |
Every table is wide in metrics: a metric is a column, never a value in a
metric_name column. A round row carries every metric measured on that round
side by side, so a model can be fitted to it directly.
One table comes back as a bare CSV. Two or more come back as a zip with a
columns.csv legend naming each column's family, its unit, and how many runs
filled it. That coverage count matters because a blank is ambiguous: a knob a
scenario never declared and a knob it declared as null both render empty. The
legend is what says which columns were sparse.
agent_level.csv is keyed on the run's registered agents rather than on what
the metrics reported. It is the roster of who ran under which model even when no
metric has a per-agent number to add.
repeat_run_columns copies the run context onto every row of the round, agent
and message tables, so each row stands alone without a join back to
run_level.csv. On by default.
Column prefixes¶
Column names are prefixed by where they came from, because a scenario is free to
declare a knob called status or perplexity, and those are also a run field
and a metric name.
| Prefix | Holds |
|---|---|
| (none) | run_id, scenario_name, and run metadata: status, timestamp, message count, cost, duration, provider, models, labels, model_class |
knob. |
the run's recorded scenario_config, flattened |
label. |
labels of the form key=value, split out (budget=800 becomes label.budget = 800) |
label_flag. |
bare tags, True where the run carries one (baseline_oss becomes label_flag.baseline_oss) |
agent_model. / agent_provider. / agent_role. |
one column per agent id |
lineage. |
where a derived run came from, plus derivation_type |
metric. |
one column per evaluator |
metric_rounds. |
how many rounds that evaluator reported, the denominator behind a fraction |
Neither the knob columns nor the metric columns come from a list anyone
maintains: knobs come from what each run recorded, metrics from the names its
report carries. run_id and scenario_name are always emitted and cannot be
deselected, because every other table joins back on them.
Knobs flatten under one rule. A scalar is its own column. A mapping explodes
into dotted keys, so model_overrides becomes
knob.model_overrides.field_observer.model. A list stays one column of compact
JSON: a list like scheduled_events varies in length per run, and exploding it
by index would invent columns that mean different things in different rows.
model_class is open, closed, or mixed, read from the providers the run's
agents used, each classified by its weights in the
providers table. Provider rather than model
name, so a family nobody has run yet still classifies. A provider serving both
open- and closed-weight models, such as openrouter, or a provider the table does
not list, gives an empty cell rather than a guess.
The two label prefixes keep a cohort carrying both budget and budget=800
from collapsing a flag and a value into one column. They also mean filtering
never substring-matches the joined labels cell, where baseline also matches
baseline_oss.
An empty cell is not a zero¶
A blank metric cell means no number exists: the metric did not apply, was
never run, or the run has no report. A 0.0 cell means the metric ran and
counted zero, a real observation for every metric that counts occurrences.
Filling blanks with zeros would merge the two and bias any average over the
column. To tell the blank cases apart, read has_evaluation and the
runs_without_report count on the preview.
round_level.csv says it twice over: a row exists for a round only when some
selected metric reported it, and within that row a metric that said nothing
leaves its cell empty.
metric_rounds.<name> is the denominator as a number. round_success of
0.4667 is a different claim over 15 rounds than over 3, and this column is what
lets cbind(successes, failures) ~ ... skip parsing the unit string. A
metric_rounds of 0 means a run-level score with nothing per round. Empty
means the metric did not run.
Cells a spreadsheet would misread¶
Control characters are stripped from text cells and newlines become spaces. Both are legal in quoted CSV and both break line-oriented tools.
A cell beginning =, +, @, or a tab is prefixed with an apostrophe, which a
spreadsheet reads as "this is text" and does not display. Without it, a judge
note beginning @ notation established ... renders as #NAME? and is lost with
no indication. A leading - is left alone, since it is far more often a negative
number.
message_level.csv¶
One row per channel message: the text, who sent it under which model, and the numbers defined per message. The other tables aggregate this one, so having it means reading a distribution rather than a mean, and a message next to its own scores.
| Column | Holds |
|---|---|
round_number, channel_id, message_index_in_round |
where in the run it sits; the index restarts per round and channel |
is_primary_channel, team_id |
from the scenario's get_primary_channels; both empty when it could not be resolved |
sender_agent_id, sender_role, sender_model, sender_provider |
resolved from the run's roster, which the message event does not carry |
text |
what the sender composed |
delivered_text |
what the channel delivered, which differs under a transform like veyru's noise |
chars |
len(text), the per-message value mean_chars_per_message averages |
character_entropy_bits, gzip_compression_ratio |
recomputed here with the same helpers those metrics use |
repetition_factor |
joined by message_id from the language_repetition sidecar, empty when the metric never ran |
Every channel is exported, so filter on is_primary_channel rather than having
the export decide. Surprisal is not
recomputed here, because it needs the metrics-ml extra a browsing server does
not install. Its per-round means are on round_level.csv.
round_context.csv¶
One row per run and round, with injection.<agent_id> holding the briefing that
agent got at the start of the round and postmortem_injection.<agent_id> the one
that opened its postmortem. This is the per-round prompt: read beside
message_level.csv it gives what an agent knew going in against what it then
said. The columns are the roster, so a sheet reads one agent's briefings down a
column instead of pivoting.
It stays separate from round_level.csv even though both are keyed on
(run, round). These cells are the largest text in the export, and a table
people join to and filter on should not carry them: in the exporter this mirrors,
repeating the briefings was ~86% of a file. Join on run_id + round_number.
How the event-log tables read¶
message_level.csv and round_context.csv read every selected run's event log,
which is why neither is in the default set. Runs are read one at a time, so
memory does not grow with the selection. Asking for both reads each log twice.
Only message_sent, tool_result_received and injection_delivered are parsed,
with the postmortem phase tracked from postmortem_started. Parsing every line
would fail the export on an event these tables discard, the moment a run predates
a required field some scenario event gained. A line that fails anyway is skipped
and counted in the log, so one damaged run costs its own rows and not the export.
is_primary_channel and team_id come from rebuilding the scenario from the
run's recorded config. A config predating a newer required knob is backfilled
from the scenario's shipped presets, the run's own values always winning. When no
preset rebuilds, both columns render empty and the log names the last error.
Selecting runs¶
Either specific runs or everything matching a filter, never a mix. In the UI that is a radio button. Over the wire it is a tagged union, so there is no precedence rule to get wrong. Checking rows in the runs list builds an explicit selection, and "Select all loaded" covers the pages fetched so far. Filter mode resolves server-side, where the whole set is known.
Filtering by knob¶
--knob takes one condition written <knob><operator><value>, with the operator
one of = != >= <= > <. Repeat it to require several, and every condition
must hold.
glossogen export --out ./out \
--scenario veyru \
--knob 'round_time_budget_seconds>=200' \
--knob postmortem_enabled=true
Quote any condition containing > or <. Unquoted, the shell reads them as
redirection: --knob round_time_budget_seconds>=200 writes a file named =200
and the flag never arrives.
The comparison is typed from the value the run recorded, not from the scenario's
knobs schema, so a run recorded before a knob changed type still answers. A
number compares numerically. A boolean takes true/false and refuses the
ordering operators. A string compares case-insensitively under = and != only.
A value that cannot be read as the recorded type is refused rather than matched
against nothing: round_count>=lots against a run recording a number, or
postmortem_enabled=maybe against one recording a boolean, is a 422 over REST and
a non-zero exit naming the knob, the operator and the value on the CLI.
A knob holding a list or a mapping is not filterable. A nested knob is addressed
with dots, the way the CSV names its column:
--knob 'model_overrides.field_observer.model=gpt-5.4'.
null names a recorded null, and is the only reserved value (none and unset
are ordinary strings). swap_round=null keeps the runs that never swapped,
swap_round!=null the ones that did. swap_round!=16 includes a run that never
swapped, since not swapping at all is not swapping at round 16. The ordering
operators never match a null. judge_model= is not a null test, it asks for the
empty string.
A knob the run never recorded keeps nothing, even under !=: the run cannot
answer the question, which is not the same as answering it in the negative. That
separates "runs that never swapped" from "runs predating the knob".
A condition with no operator is refused rather than dropped, since dropping it would export a wider set than was asked for. A condition naming a knob no scenario in the selection declares is not an error and selects nothing.
The same conditions apply in the UI's filter bar and over REST, as the
selection's knob array.
CLI¶
Reads the runs directory directly, so it needs no server and no database, and it covers runs that were never evaluated and runs still in progress.
glossogen export --runs-dir ./runs --out ./export \
--label baseline_oss --label budget=800 \
--frames run_level,round_level \
--raw
| Flag | Effect |
|---|---|
--out DIR |
where to write (required) |
--scenario NAME |
only these scenarios (repeatable) |
--label LABEL |
only runs carrying every one of these (repeatable) |
--run-id-contains SUB |
substring of scenario/run_dir_name |
--contains-agent-id ID |
only runs that registered this agent |
--knob COND |
only runs whose recorded config satisfies this condition (repeatable, AND-matched) |
--status STATE |
only runs in one state, e.g. scenario_complete to skip crashed runs |
--run-id ID |
exactly these runs (repeatable); cannot be combined with any filter flag |
--frames |
which tables, comma-separated; the default is the first three |
--include-metric-summaries |
add each metric's unit and summary at run level, and its per-observation note on the round and agent tables |
--no-repeat-run-columns |
keep the round, agent and message tables narrow |
--raw |
also write runs.zip |
--include-logs |
keep debug and stdout logs in that zip |
--include-atif |
with --raw, add each agent's ATIF trajectory to that zip |
--max-runs N |
override the 5000-run ceiling |
Every column and metric available for the selection is included. The flags trim tables, not columns. Use the REST endpoint to pick columns.
REST¶
All three are POSTs, because a selection can carry hundreds of run ids and a column list a hundred keys, which is more than a query string reliably holds.
| Endpoint | Returns |
|---|---|
POST /api/g/{slug}/runs/export/preview |
JSON: run count, scenarios, available columns and metrics with per-column coverage, row counts per metric, and the limits |
POST /api/g/{slug}/runs/export/csv |
one CSV, or a zip of tables plus the legend |
POST /api/g/{slug}/runs/export/raw |
zip of the run folders |
The preview and the downloads share one selection model, so the preview describes exactly what the download produces.
curl -X POST "$API/api/g/local/runs/export/csv" -H 'content-type: application/json' -d '{
"selection": {"kind":"filters","scenario":["veyru"],"labels":["baseline_oss"],
"run_id_contains":null,"status":null,"contains_agent_id":null,
"knob":["round_time_budget_seconds>=200"]},
"frames": ["run_level"],
"columns": ["status","knob.round_count","label.budget"],
"metrics": ["round_success","mean_chars_per_round"],
"repeat_run_columns": false,
"include_metric_summaries": false
}' -o run_level.csv
An explicit id that no longer resolves shows up on the preview, and a download refuses it with a 404. Refusing is the safer failure: a table with one row fewer than asked for looks exactly like a correct one. The export modal drops such ids from the request and reports how many, leaving the 404 for a run deleted between preview and download.
Limits¶
| Ceiling | Value | Counts | How |
|---|---|---|---|
| runs per export | 5000 | runs in the selection | before the build |
| raw zip | 4 GiB | the run folders, uncompressed | estimated before the build |
| CSV tables | 512 MiB | bytes the client receives | counted during the write |
The run ceiling sits above the largest labelled cohort here. The raw ceiling is conservative on purpose: it sizes folders on disk, and the delivered zip measured 5.7x to 7.0x smaller across three scenarios here, so 4 GiB counted is roughly 600 MiB received.
The CSV ceiling applies to the REST endpoint only. glossogen export writes to a
directory and holds no table in memory. For a single CSV, counted equals
delivered, and one cohort's round_level.csv alone reaches 273 MiB, which is a
large object for a browser tab to hold. Anything near that belongs on the CLI.
The raw ceiling does apply to the CLI, and --max-runs raises the run ceiling
locally.
Past any ceiling the download refuses, naming which limit and what to change. The
preview still answers, and reports all three limits so an interface quotes the
value the endpoint enforces. The archive is built into a temporary file under
TMPDIR. Point that at a larger volume if the default is small.
What the raw zip holds¶
Each run nests under {scenario_name}/{run_dir_name}/, so extracting at runs/
reproduces the layout. A manifest.csv at the root names what went in.
Debug and stdout logs are left out by default, since a run's _debug.jsonl is
routinely larger than the event log. --include-logs or the modal checkbox puts
them back. stream.json and eval_in_progress.json are always left out: they
describe work in flight, so a re-imported run carrying them reads as still
running.
An atif/ folder in each run holds one ATIF trajectory per
agent, generated from the event log while the zip is written. The modal's ATIF
checkbox starts ticked. The CLI adds it with --include-atif, and the REST body
with include_atif. A single run's zip download always includes it.
A run still being written is safe to export. Members are streamed into the archive rather than pre-declaring a size, so a JSONL that grows mid-read does not corrupt its entry.
Not covered here¶
The ground-truth meaning behind a round: what the scenario planted, what it expected, which sub-stage a message belongs to. A hand-written exporter reads a scenario's own case events to get there. Making that scenario-agnostic needs a contract for declaring the meaning, so it is tracked separately.
Pre-aggregated frames, the per-cell n / mean / std a hand-written exporter
ships. That is one groupby in R or Pandas, and shipping it would bake in the
grouping keys.