File format¶
This page is the specification of what NuBoard writes to disk. It is updated
with every format change. The current run folder format is version 1
(format_version in run.json); the event log format is version 1
(the last two bytes of the log's magic).
Everything a run produces lives in its own folder. A run folder never refers to anything outside itself, so it can be copied, rsynced, zipped or deleted on its own. There is no database next to the runs and no lock on the write path.
Layout¶
<root>/ any folder; may hold many projects
<project>/ may be nested: team/llm/<project>/
runs/
<YYYYMMDDTHHMM>-<hash8>/ one run (the folder name is the run id)
run.json metadata, rewritten atomically
annotations.json viewer-owned: star, notes, tags, archived, reviewed, fields
events.r<rank>.s<session>.log append-only event log, one per rank and session
media.r<rank>.pack images, video, figures and HTML appended back to back
media.r<rank>.s<session>.pack the same, for a session of a helper job (see Jobs)
jobs/<job>.json state of a helper job: status, heartbeat, summary, ...
data.parquet compacted scalars (written at finish, optional)
files/
requirements.txt installed packages (pip freeze format)
git.patch `git diff HEAD` at start (only if dirty, at most 10 MB)
git-<package>.patch uncommitted changes of a package installed from a local folder
command.txt the command line that started the run
output.log what rank 0 printed (capture_console)
... files and folders added with run.add_file
artifacts/<name>/v<N>/
manifest.json hashes, sizes, type, metadata, producing run
files/... the artifact's files (absent for a reference)
artifacts/.unlinked/<name>/v<N>/ versions unlinked in the viewer, kept as they were
collections/<name>.json hand-picked sets of run ids; safe to commit to git
views/*.json saved viewer state; safe to commit to git
sweeps.json Sweeps settings: keys hidden from the picker
reports/<name>.md Markdown reports written in the viewer
reports/files/*.png, *.svg chart and plot snapshots embedded in reports
reports/files/<hash>.<ext> images, audio and text files uploaded into reports
fields.json typed property definitions of the project
hypotheses/<board>/board.json a hypothesis board: title, categories, chip number
hypotheses/<board>/<id>.json one hypothesis of that board
~/.cache/nuboard/<hash>.sqlite disposable run catalog (see below)
Git info¶
| Key | Type | Meaning |
|---|---|---|
commit |
str/null | HEAD's full hash; null in a repository with no commits yet. |
branch |
str/null | The checked-out branch; null when HEAD is detached. |
remote |
str/null | remote.origin.url, with any user, password, query and fragment removed from a URL with a scheme. |
root |
str | The working tree's top folder. |
dirty |
bool/null | Whether files/git.patch has changes; null when the diff could not be read. |
pushed |
bool/null | Whether a remote-tracking branch contains commit (read locally, as of the last fetch or push); null when there are none or it could not be read. |
untracked |
int/null | Untracked, not ignored files: their number only. |
source |
str | Where the repository was found: code_dir, script or cwd. |
patch_bytes |
int | Only when the diff is over 10 MB: its size. git.patch is then not written. |
pushed, untracked, source and patch_bytes are absent from runs recorded by older versions.
Every git command runs with core.fsmonitor off, and diffs with
--no-ext-diff --no-textconv, so a repository's config or .gitattributes
cannot make capture run a program.
requirements.txt¶
files/requirements.txt is in pip install -r format, one package per line:
| Installed from | Line |
|---|---|
| an index (PyPI) | name==version |
| a VCS URL | name @ git+URL@commit (URL without credentials) |
| an archive URL | name @ URL (without query or fragment) |
| a local folder, clean git checkout whose commit is on a remote | name @ git+URL@commit[#subdirectory=dir] # local: <path> |
| a local folder with uncommitted changes | name==version # local: <path>, git <commit> (dirty), patch: git-<package>.patch |
| a local folder, commit not pushed | name==version # local: <path>, git <commit> (not pushed) |
| a local folder inside the run's repository | name==version # local: <path>, the run's repository (see run.json git) |
| a local folder not under git | name==version # local: <path>, not under git |
| a local folder that no longer exists | name==version # local: <path> (missing) |
| a local file, or a 21st or later local folder | name==version # local: <path> |
Packages from one repository share one patch, named after the first of them.
A patch over 10 MB is not written and the line says patch too large (N bytes).
The remote of a local folder is written with an ssh:// URL when it is in
scp form (git@host:org/repo), and kept as a comment (remote <path>) when it
is a folder on the same machine.
Run ids and names¶
- Run id:
<YYYYMMDDTHHMM>-<hash8>, e.g.20261013T0720-755037e9. The timestamp is local time atinit.<hash8>is 8 random lowercase hex digits (32 bits), andinitdraws again if the folder already exists, so ids are globally unique in practice and never collide on case-insensitive filesystems. An explicitrun_idpassed toinitis used as given. Runs created before 0.17 keep their<YYYYMMDD-HHMM>_<name>_<rand6>ids; readers treat ids as opaque strings. - The id does not contain the run name.
run_name(stored asnameinrun.json) is a free label; no colons appear in any file name, so folders work on Windows. - In a distributed job with a shared id (
TORCHELASTIC_RUN_IDorSLURM_JOB_ID),<hash8>is the first 8 hex digits of the SHA-1 of that id so every rank finds the same folder.
run.json¶
UTF-8 JSON, written by rank 0 only, always via a temporary file and
os.replace (retried on Windows while a reader holds the file). Readers must
ignore keys they do not know.
| Key | Type | Meaning |
|---|---|---|
format_version |
int | Run folder format version (1). |
run_id |
str | Same as the folder name. |
project, group |
str | As given to init. |
name |
str | The run_name given to init (default "run"). |
tags |
list[str] | Tags given to init (the viewer adds its own in annotations.json). |
notes |
str | Notes given to init. |
config |
object | The config passed to init. |
status |
str | running, finished, crashed or killed. |
created, started, ended, heartbeat |
float | Unix seconds. heartbeat is refreshed every 10 s while running. |
sessions |
list | One {session, started, ended} per start or resume, plus the machine's utc_offset, utc_offset_end and host; see Session time zones. |
summary |
object | Per scalar: last, step, min, max, count, nonfinite. Non-finite last values are the strings "nan", "inf", "-inf". |
baselines |
object | Per scalar, set with add_scalar_baseline: {value}, plus low and high when given. Absent when none was set. |
git |
object/null | commit, branch, remote, root, dirty, pushed, untracked, source; see below. Null outside a git repository or without git. |
env |
object | host, platform, python, executable, argv, cwd, pid, gpus, cuda_visible_devices. gpus lists the NVIDIA GPUs the process can use (after CUDA_VISIBLE_DEVICES), each with device (cuda:i), index (on the machine), uuid, name, memory and driver; older runs have only the last three, for every GPU of the machine. |
distributed |
object | world_size, shared_id. |
prefixes |
object | {system, timing}: the first segment of the system metric and timer names, "system" and "timing" unless changed with init's system_prefix and timing_prefix. Absent from older runs, which use the defaults. |
inputs, outputs |
list | Artifacts used / logged: {artifact, version}, plus project and project_path for another project's artifact (see Artifacts). |
fork |
object/null | {run_id, step} when created with fork_from. |
exit |
object/null | {code, error}; error holds the last 4,000 characters of the traceback. |
media_count |
int | Media and text items logged, across sessions; a helper job keeps its own in jobs/<job>.json. Absent from runs recorded by older versions; the viewer then checks for a media.r*.pack file. |
compacted |
object | Written after compaction: {bytes: {log file: size}, time}. |
alerts |
list | Written after compaction: the run's health alerts, see below. |
A run whose status is running but whose heartbeat is older than 120 s is
shown as lost (the process died without running its exit handlers, e.g.
SIGKILL or a node failure). Nothing is written to the folder to mark it.
Session time zones¶
All times in a run folder (created, started, ended, heartbeat, and the
time column of every frame) are Unix seconds, so they do not depend on the
machine's time zone. Each sessions entry also records the zone of the
machine that ran it, for display only:
| Key | Type | Meaning |
|---|---|---|
utc_offset |
int | The machine's offset from UTC in seconds when the session started, east positive (-10800 is UTC−03:00). Worked out by the operating system, daylight saving included. |
utc_offset_end |
int | The same at finish; differs from utc_offset when the clocks changed during the session. |
host |
str | The host name that ran the session. Left out with capture_env=False (helper jobs always record it, as in their env). |
A run resumed on another machine gets one entry per machine. The keys are
additive: format_version stays 1 and readers ignore keys they do not know.
Runs recorded by 0.21 and earlier lack them, and so do TensorBoard imports,
whose times come from another machine; readers then fall back to the run id,
whose timestamp is the machine's local time at init, to the minute: the
difference between it and created, rounded to 15 minutes, is the first
session's offset.
annotations.json¶
Owned by the viewer, written atomically: starred (bool), archived (bool),
reviewed (bool), notes (Markdown str, overrides run.json notes in the
viewer), tags (list; once tags are edited in the viewer, this list replaces the
run.json tags, which keep what was logged), fields (object of property
values: string, number, boolean or list-of-string values; lists hold the
values of tags properties), audio_notes (object keyed by
<media key>@<step> of rank 0, each a list of {id, start, end, text} notes on
sections of that audio item, times in seconds). Keeping these inside the run
folder means they travel with the run.
alerts in run.json¶
Each alert is an object:
| Key | Type | Meaning |
|---|---|---|
kind |
str | nonfinite, spike or drop. |
metric, rank |
str, int | Where it happened. |
step |
int | First step of the problem. |
end_step |
int | Last step (spike and drop). |
severity |
str | error (non-finite) or warning. |
message |
str | A readable one-line description. |
value, baseline |
float | Peak or lowest value, and what it was compared with (spike, drop). |
count |
int | Number of non-finite values (nonfinite). |
The list is recomputed whenever the run is compacted. Readers treat a missing key as "not checked yet", not as "healthy".
Jobs: jobs/<job>.json¶
A process that joins a run with init(..., run_id=ID, job="eval") is a helper
job. It never writes run.json: its state goes in jobs/<job>.json, with the
same keys run.json uses for a process (status, started, ended,
heartbeat, exit, sessions, summary, inputs, outputs,
baselines), plus job, heartbeat_interval, env (host, pid) and
command. Its scalars go to event logs numbered after the run's other
sessions, and its media to media.r<rank>.s<session>.pack, so two processes
never append to the same file.
Readers merge the jobs into the run: summaries and baselines are added (the
main job's entry wins on a shared name), inputs and outputs are appended with
job set, and jobs lists each job's job, status, started, ended,
heartbeat, error and host. The run's status is the main job's.
Readers older than 0.16 ignore jobs/ and show the main job only.
Event log: events.r<rank>.s<session>.log¶
One file per rank and session. A session is one process lifetime: a new run starts session 0; each resume writes the next session number. Files are only ever appended to.
file := MAGIC frame*
MAGIC := "RUNLOG" 0x00 0x01 8 bytes; the last two bytes are the log version
frame := kind:u8 length:u32 crc32:u32 payload[length]
All integers and floats are little-endian. crc32 is zlib's CRC-32 of the
payload. Strings are UTF-8 without a terminator (their length follows from
the frame length).
| Kind | Payload | Meaning |
|---|---|---|
N |
id:u32, name:utf8 |
Declares a key id (scalars, text keys and media keys share one id space per file). Always precedes the first use of the id. |
S |
n:u32, metric_id:u32[n], step:i64[n], time:f64[n], value:f64[n] |
A batch of scalars as four columns. |
H |
time:f64 |
Heartbeat. |
T |
key_id:u32, step:i64, time:f64, text:utf8 |
Text sample. |
M |
key_id:u32, step:i64, time:f64, offset:u64, length:u64, meta:utf8 JSON |
Media index entry: length bytes at offset in media.r<rank>.pack. meta holds at least mime and kind (image, audio, video, figure, html), plus caption for images, audio and video, sample_rate, channels and duration (seconds) for audio when known, and fps, frames, width, height and duration for video encoded from arrays. |
E |
time:f64, status:utf8 |
End of session, written by finish(). |
Scalars named system/... are the system samples
of system_metrics. They are ordinary S frames, written by LOCAL_RANK 0
of each machine into its own rank's log. Their step is the latest training
step, so several samples can share a step, in time order.
Timing keys¶
Scalars named timing/... are timers. They are ordinary
S frames with one difference in meaning: value is the span's duration in
seconds, and time is the wall-clock time the span started (not when it
was logged), so time + value is when it ended. A helper job's timers are
named timing/<job>/.... A timer made with accumulate=True logs one point per
flush: value is the summed duration, time the start of the first summed
span, and the hidden key <name>\x1fcount (like the \x1flow and \x1fhigh
band keys) holds how many spans were summed.
Timers are written by rank 0, or by every rank for those made with
all_ranks=True, each into its own rank's log. Spans that nest (a
data/wait inside train/epoch) are not linked in the file: a reader finds
the nesting from the intervals of one rank.
system and timing are the defaults of the run's prefixes (see
run.json); a run that changed them uses its own in place of
system/ and timing/.
A scalar logged with low and high writes them as two companion scalars
in the same S frame, named <key> followed by the unit separator 0x1f
and low or high ("val/loss\x1flow"). Readers draw them as the band of
<key> and leave them out of scalar lists and the summary in run.json.
Older readers show them as two extra scalars.
Readers skip unknown kinds, so new frame kinds can be added without a version bump.
Crash safety. A reader stops at the first frame that is incomplete or
whose CRC does not match, and remembers the offset of that frame. A torn final
frame (process killed mid-write) is therefore ignored; if the bytes arrive
later (slow network filesystem), the next incremental read picks the frame
up. The writer writes media bytes to the pack and flushes them before the
M frame that points at them.
Values. time is Unix seconds from time.time() unless the caller passes
time=. value is any float64, including NaN and ±Inf.
Merging sessions and ranks¶
For one rank, sessions are combined in order. When a later session logs a
scalar, every point of that scalar with step >= the later session's first
step for that scalar is dropped from the earlier sessions: the later session
wins on overlapping steps (a resume from a checkpoint replaces what was
logged after the checkpoint). Ranks are never merged; each rank is its own
series and rank 0 is the default.
Media pack: media.r<rank>.pack¶
Raw bytes of images (PNG/JPEG/GIF/WebP/SVG), audio (WAV/FLAC/MP3/Ogg/MP4/WebM), video (APNG/MP4/WebM/MOV/Ogg/GIF), figures (Plotly JSON,
Vega-Lite JSON, Bokeh JSON, or SVG for matplotlib), PR curves, distributions, tables, point clouds and HTML documents,
appended back to back with no framing. Only M frames give them meaning, so
bytes after the last indexed entry (from a crash) are harmless.
mime |
Written by |
|---|---|
image/png, image/jpeg, ... |
add_image (arrays are PNG-encoded with the standard library) |
audio/wav |
add_audio with an array (16-bit PCM, standard library wave) or a WAV file |
audio/flac, audio/mpeg, audio/ogg, audio/mp4, audio/webm |
add_audio with a file or bytes of that format, stored unchanged |
image/apng |
add_video with an array: an animated PNG, one fcTL per frame with the delay 1/fps, looping, filtered with the PNG Up filter |
video/mp4, video/webm, video/quicktime, video/ogg, image/gif |
add_video with a file or bytes of that format, stored unchanged |
image/svg+xml |
add_figure with a matplotlib figure |
application/vnd.plotly.v1+json |
add_figure with a Plotly figure or a figure dict |
application/vnd.vegalite+json |
add_figure with an Altair chart or a Vega-Lite dict |
application/vnd.bokeh+json |
add_figure with a Bokeh figure |
application/x-nuboard-pr+json |
add_pr_curve: {thresholds, tp, fp, tn, fn, precision, recall}, one value per threshold (thresholds evenly spaced from 0 to 1); the frame's thresholds and positives give the count of thresholds and positives |
application/x-nuboard-histogram+json |
add_histogram: {edges, counts, count} with edges one longer than counts (every bin includes its left edge, the last also its right edge), and optionally min, max, mean, std of the finite values, outside (finite values outside the edges) and nonfinite (NaN and infinite values, not binned); count includes both. The frame's bins and count give the counts |
application/x-nuboard-table+json |
add_table: {columns: [{name, type}], id, rows}; type is number, bool, text, audio or image, id names the column that matches rows across steps, and each row is a list of cells in column order. A media cell is {"$cell": "<key>#<row>/<column>"}, the key of a media item logged at the same step with kind cell (not listed on its own in the viewer). The frame's rows and columns give the counts |
application/x-nuboard-points |
add_point_cloud: N x 3 little-endian float32 x, y, z, then N x 3 RGB bytes when the frame's colors is true; points holds N, and axes, when present, the three axis names the viewer starts showing |
application/x-nuboard-waveform |
add_figure with a nb.WaveformFigure: the audio as 16-bit PCM WAV (audio_bytes long), then each signal as samples little-endian float32 values, in the order of the frame's signals; the frame also holds sample_rate, channels, duration, range, caption and regions ({track: [[start, end, label], ...]} in seconds, or null) |
text/html |
add_html (the viewer renders it in a sandboxed iframe) |
Waveform bundles¶
A waveform figure downloads (Data (.zip) in the viewer and in exported HTML) and goes into a report's .zip export as a bundle, a zip with every member stored uncompressed:
<run>-<key>-<step>.zip
audio.wav the audio, byte for byte as logged (16-bit PCM)
signals/<name>.npy one float32 array per signal, one value per audio sample (NaN = gap)
metadata.json
Each .npy is NumPy format 1.0 with descr <f4 and shape (samples,). A
signal's file name is its name with characters some systems do not allow in
file names (/ \ : * ? " < > | and control characters) replaced by _; names
that then collide, ignoring case, get -2, -3, ...; metadata.json maps
each name to its file.
{
"format": "nuboard-waveform", "format_version": 1,
"run": {"id": "20261028T1012-1a2b3c4d", "name": "unet-l1", "project": "speech-denoise"},
"key": "eval/vad", "step": 1200, "caption": "VAD on sample 3",
"audio": {"file": "audio.wav", "sample_rate": 16000, "channels": 1, "samples": 160000, "duration": 10.0},
"signals": [{"name": "speech_prob", "file": "signals/speech_prob.npy", "dtype": "float32", "samples": 160000}],
"range": [0.0, 1.0],
"regions": {"target": [[0.42, 1.87, "speech"]], "predicted": [[0.40, 1.95, "speech"]]},
"notes": [{"id": "...", "start": 0.4, "end": 0.6, "text": "false alarm"}],
"exported_with": "nuboard 0.18.0"
}
samples counts per channel. range is the values at the bottom and top of
the plot ([-1, 1], the waveform's own scale, unless the figure set one),
regions is null when none were logged, and notes lists the notes added in
the viewer. Readers ignore keys they do not know; a newer format_version
means a change they cannot read. nb.WaveformFigure.load(path) rebuilds the
figure from a bundle.
Compacted scalars: data.parquet¶
Written by finish() when PyArrow and NumPy are installed, or by
nuboard compact <dir>. It is a cache of the logs: the logs stay the source of
truth and are never deleted.
Columns: metric: string, level: int8, rank: int32, step: int64,
time: float64, value: float64, vmin: float64, vmax: float64.
- Rows are sorted by scalar, zoom level, rank, step, and there is exactly one row group per (scalar, level).
- File key/value metadata
row_groupsholds a JSON map{metric: [[row_group_index, rows], ...]}indexed by level, so reading one scalar at one level is one row-group read.format_versionis also stored. - Level 0 is the raw data (
vmin/vmaxequalvalue). Level k averages consecutive buckets of 8^k points:step/timeof the bucket's first point,value= mean of finite values,vmin/vmax= min/max. Levels stop when fewer than 512 points would remain. - Compression is zstd.
Freshness. After writing the file, the compactor records the byte size of
every log file in run.json → compacted.bytes. Readers use data.parquet
only when those sizes equal the current sizes; otherwise (for example after a
resume added a session) they read the logs. No coordination is needed.
Artifacts¶
artifacts/<name>/v<N>/ is created by copying into a staging folder
(artifacts/<name>/.staging-<rand>/) and renaming it into place, so a version
is either complete or absent. A staging folder left by a killed process is
ignored by readers, and removed once nothing in it changed for 6 hours. A
version's source can never be inside an artifacts/ folder or contain one,
so no version lists another version's files. manifest.json:
{
"name": "model", "version": 1, "type": "checkpoint",
"description": "Final checkpoint of the lr=1e-3 seed 1 run",
"metadata": {"step": 25000},
"files": [{"path": "model.safetensors", "size": 2048, "sha256": "…"}],
"created": 1791970000.0, "producer_run": "20261014T0831-f0885bdb",
"created_by": {"user": "esteban", "host": "gpu-box", "command": "train.py --lr 1e-3"}
}
A reference version (reference=True) has no files/ folder. Its
manifest records where the files are and a fingerprint of each one:
{
"name": "fineweb-edu", "version": 2, "type": "dataset",
"metadata": {"tokens": "12B"},
"reference": "/data/fineweb-edu/2026-10",
"root": "/data/fineweb-edu/2026-10",
"files": [{"path": "train.txt", "size": 3240, "mtime_ns": 1792279527703318788}],
"created": 1791970000.0, "producer_run": null
}
| Key | Meaning |
|---|---|
reference |
Path of the file or folder that was referenced: absolute, or starting with an environment variable, as in ${NUBOARD_DATA_DIR}/fineweb-edu/2026-10, which readers expand with their own environment, or relative to the project folder when reference_base is "project". |
reference_base |
"project" when the referenced path is inside the version's own project, such as a run's files (runs/<run>/files/best.pt): reference and root are then relative to the project folder, with / separators, so they keep working when the project is copied or moved. Absent otherwise. Since 0.24. |
source |
Where the files came from, for a version made from a run's files: {"run": <run id>, "path": <path inside the run folder>}, by copy or by reference. Absent otherwise. Since 0.24. |
root |
The folder files[].path is relative to: reference for a folder, its parent for a file. |
files[].mtime_ns |
Modification time in nanoseconds, for information. Readers compare size to detect changes, since copies on other machines have their own times. |
files[].sha256 |
Present for every copied file, and for referenced files only with checksum=True. |
producer_run |
null for a version added with nb.add_artifact or nuboard artifact add, outside a run. |
description |
Free text saying what the version is; "" when none was given. Since 0.15. |
created_by |
{user, host, command}: who created the version, on which machine, with which command line. user is null when the environment has no user name. Since 0.15. |
Runs record what they used and produced in run.json (inputs, outputs).
An entry for an artifact of another project adds project, its name relative
to the root folder, and project_path, the path from the run's project
folder to that project's folder, which is what the viewer resolves:
{"artifact": "fineweb-edu", "version": 2, "project": "datasets", "project_path": "../datasets"}
A version made from a run's files by reference depends on that run folder
being in the project: the dependency goes from the project's artifacts/
into one of its own runs, never from a run outward, so a run folder stays
self-contained. nuboard rm keeps such a run unless forced.
A project may hold only artifacts and no runs, such as a datasets project.
A run folder copied alone keeps its record of which artifact versions it used
but not the artifact files, and a reference keeps only the path, so the data
has to stay where it was for the version to be usable.
Unlinking a version in the viewer moves its folder, unchanged, to
artifacts/.unlinked/<name>/v<N>/; restoring moves it back. Unlinked versions
are not listed by list_artifacts or nuboard artifact ls and cannot be
loaded with use_artifact, but their numbers stay taken: the next version of
<name> is numbered after the highest version in either folder. Runs keep
their inputs and outputs entries, and the viewer shows them as unlinked.
Collections: collections/<name>.json¶
{"name": "shortlist", "runs": ["20261014T1045-9dc071b6", "…"],
"description": "Candidates for the 7B run", "created": 1791980000.0, "updated": 1791981000.0}
runs holds run ids (folder names) of the same project, in the order they
were added. Ids of runs that no longer exist are ignored. Written atomically;
deleting the file deletes the collection.
Hypothesis boards: hypotheses/<board>/¶
A project can have several boards, one folder each. Board names and
hypothesis ids are 1 to 64 letters, digits, - or _. Everything is written
atomically by the viewer and is safe to commit to git.
board.json (optional; a board without it uses the defaults):
{"title": "Augmentations on ViT-S/16", "description": "Which augmentation …",
"verdicts": [{"id": "supported", "label": "Supported", "color": "s4", "icon": "check"}, "…"],
"outcomes": [{"id": "dropped", "label": "Dropped", "color": "gray", "dead_end": true}, "…"],
"card": {"metric": "val/acc", "stat": "best", "higher": true}}
verdictsandoutcomeshold 1 to 8 categories each.coloris one ofs1…s7(the viewer's series colors) orgray;iconis one ofcheck,x,circle,star,question,pause. Missing or empty lists give the defaults: Supported, Rejected and Open for verdicts; Continued, Open and Dropped (a dead end) for outcomes.cardis the number shown on each run: a metric and itsbest,last,minormaxvalue;highersays which way is best.nullshows none.
<id>.json, one hypothesis (the file name is its id):
{"id": "cutmix", "title": "Pasting patches keeps the gain", "note": "Markdown …",
"parent_run": "20261101T2007-3c1e88a0", "reason": "keep local structure", "show_reason": true,
"also": [{"run": "20261101T1837-9b0d71aa", "reason": "the bar to beat"}],
"runs": [{"id": "20261101T2037-5f2a9e10", "outcome": "continued", "conclusion": "Best run.", "star": true}],
"verdict": "supported", "star": true, "reports": ["augmentation-findings"],
"position": {"dx": 0, "dy": 0}, "folded": null, "card": null,
"created": 1791980000.0, "updated": 1791981000.0}
parent_runis the run the hypothesis came from (the arrow), ornullfor a starting point;reasonis the text on the arrow, hidden whenshow_reasonis false.alsolists dashed "also informed by" links.runsare run ids of the same project, each with an outcome id, a conclusion and a star. A run is listed by at most one hypothesis of a board.verdictandoutcomehold category ids; an unknown id shows as Open (or the first category).reportsare report names of the project.positionis how far the card was dragged from its automatic place;foldedisnullfor automatic (folded above five runs);cardoverrides the board's number.
Runs and reports that no longer exist show as "not found". A parent link that would make a loop is refused on save and drawn as a starting point if it is found on disk. Files that are not JSON objects are skipped, and unknown or invalid values fall back to defaults.
Saved views: views/*.json¶
{"page": "charts", "params": {"q": "...", "metric": "...", ...}}, where
params are the viewer's URL parameters. Safe to commit.
Sweeps settings: sweeps.json¶
{"hide": "!seed !.path"}: keys the filter excludes are hidden from the Sweeps
parameter picker and its defaults. The filter uses the run settings filter
syntax and is at most 1000 characters. Written by the viewer; safe to commit.
Reports: reports/<name>.md¶
Plain Markdown, written atomically by the viewer and safe to edit by hand or
commit. The first # heading is the report's title. A logged item is embedded
with an image line whose target is a media reference:

that is media:<run id>/<key>@<step>, where the run id is the run's folder
name in the same project and <key> may contain /. A reference that no
longer resolves is shown as missing. In a .zip export, an embedded waveform
figure is its bundle in files/, linked from the
Markdown.
A chart saved from the Charts page is an ordinary image of a PNG snapshot in
reports/files/, whose title describes the chart as a URL query string after
chart::

Keys: metric, runs (comma-separated run keys), x, group, agg,
smooth, raw=0, robust=0, log=1, zoom=<from>~<to> and hide
(series labels separated by newlines). The viewer draws the chart live from
these and shows the snapshot when it cannot; other Markdown viewers show the
snapshot.
A snapshot from another page (Sweeps, Timeline) has the title
view:<page>?<viewer URL parameters>; the viewer shows the image with a
button that reopens that page with those parameters. Timeline snapshots are
SVG files (no scripts, event handlers or links; each bar has a <title>), the
others PNG.
The Media page's "All runs on one chart" has the title
overlay:<ref> <ref> ..., one <run id>/<key>@<step> reference per run
(as after media:), separated by spaces. The viewer draws the chart live
from those items and shows the PNG snapshot when none of them is found.
Uploaded files¶
Images, audio and text files uploaded in the report editor are stored in
reports/files/ as uploaded, named by the first 16 hex digits of the SHA-256
of their bytes plus an extension (files/3f2a9c0d1e2b4a5f.png), so identical
uploads share one file.
- Images:
.png,.jpg,.gifor.webp, the extension chosen from the file's first bytes, or.svgfor an SVG drawing with no scripts, event handlers, links or embedded HTML. Embedded as. - Audio:
.wav,.flac,.mp3,.ogg,.m4aor.webm, chosen from the file's first bytes (RIFF/WAVE,fLaC, an ID3 tag or MPEG frame sync,OggS,ftyp, EBML). Embedded as[caption](files/<hash>.<ext>), which the viewer plays with the waveform player. - Text files: UTF-8 with no NUL bytes; the extension is the uploaded
file's when it is a known text or source extension (
.txt,.md,.log,.csv,.json,.yaml,.py, ...), else.txt. An attached file is a link titledattachment:[train.log](files/<hash>.log "attachment").
The viewer serves images inline with their type, and every other file as a
download (text/plain; charset=utf-8 for text), always with
X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'.
Property definitions: fields.json¶
{"fields": [
{"name": "verdict", "type": "choice", "options": ["keep", "drop", "rerun"]},
{"name": "gpu_hours", "type": "number", "unit": "h"},
{"name": "paper", "type": "link", "description": "Where the idea comes from"}
]}
type is one of text, number, choice, tags, bool, date (an ISO
YYYY-MM-DD string) or link. options is used by choice and tags,
unit by number; description is optional. The values live in each run's
annotations.json fields; a value without a definition is shown as text.
A value must fit its definition: a number for number, true/false for
bool, one of options for choice, a list drawn from options for tags
(any value when options is empty), YYYY-MM-DD for date and an http(s)
address for link. The viewer and Run.set_field refuse values that do not.
The catalog cache¶
~/.cache/nuboard/<sha1(root)[:16]>.sqlite (%LOCALAPPDATA%\nuboard on
Windows; override with NUBOARD_CACHE_DIR) holds one row per run with the
viewer's record of it and the mtimes it was built from. It lives on local
disk, never next to the runs, so runs can sit on network filesystems. It is
purely a cache: when its schema version (PRAGMA user_version) differs it is
dropped and rebuilt. There are no migrations.
Compatibility rules¶
- New keys may be added to
run.json,annotations.json, manifests and framemetaJSON at any time; readers ignore unknown keys. - New frame kinds may be added; readers skip unknown kinds.
- Changing the meaning or layout of an existing frame, or of the Parquet columns, requires bumping the relevant version and keeping a reader for the old one.