vigily design¶
vigily is an experiment tracker: a Python SDK logs runs, parameters and metric series; one Rust binary serves a web app and a JSON API over them. This document records my decisions, the guarantees and the alternatives I rejected. docs/architecture.md is the shorter tour of the code; brand/DESIGN.md and docs/design/brief.md cover the visual design.
Goals and non-goals¶
Goals:
- The everyday experiment-tracking workflow (runs, hyperparameters, metrics with contexts, a runs table, a metric explorer, run pages) with few moving parts: one binary, one storage library, no database server, no Node at runtime.
- Fast with thousands of runs and series of 100 000 points or more.
- A UI for long sessions: dense, keyboard-first, legible numbers, light and dark.
- Storage anywhere kv0 can write: a local directory, S3, GCS, Azure.
Non-goals for now: images, audio, text and distribution tracking; multi-user accounts; a hosted service; importing other trackers' repositories.
Crates¶
| Crate | Depends on | Why it is separate |
|---|---|---|
vigily-core |
kv0 (optional) | The model, queries and chart maths are testable without HTTP or Python |
vigily-server |
core, ui, axum | The HTTP surface; tests drive it in memory with tower::oneshot |
vigily-ui |
nothing at runtime | build.rs embeds dist, so the binary carries the app |
vigily-cli |
server | The vigily binary |
vigily-py |
core, pyo3 | An abi3 extension (one wheel per platform for Python 3.11+) |
Storage¶
A trait in front of kv0¶
vigily-core talks to storage through two traits: Store (list runs, read parameters, series and
points, create and update runs) and RunWriter (track points, set parameters, finish). Two
implementations:
Kv0Storeon kv0's tracking layer (featurekv0, on by default).MemoryStorefor tests and for embedding.
Why a trait when there is one real backend: kv0 is being rewritten at the same time as
vigily, by another author, and its tracking API is still moving. The trait isolates every change
to one file (kv0_store.rs) and lets every other test run without Lance. What vigily needs
from kv0 and does not get yet is in docs/kv0-requests.md.
Writes¶
The SDK writes straight to storage from the training process through kv0, which buffers points
and commits them in batches (every 50 000 points, every few seconds and on finish()). There is no
vigily server on the write path: training does not depend on the server being up, and many
processes can log to one repository at once, because kv0's commits are appends that do not
conflict.
Rejected: an HTTP ingest endpoint for remote tracking. It adds a server that must stay up during training and a second protocol; object-store URIs already give remote logging. It can be added later for clusters that cannot write to shared storage. Traces are the exception (see "LLM tracing"): they come from applications rather than training scripts, and their payload policy and prices belong to the operator.
Metadata updates from the server and the SDK¶
Renaming a run, editing notes and tags, and archiving go through Store::update_run; the SDK changes
a run's status, end time, name and tags through RunWriter. kv0 stores each change as a row that
records which fields it sets, and merges rows field by field, newest first. Each side therefore
writes only the fields it changes: a note saved in the web app while the training script finishes
the run keeps the run finished, and the script's finish keeps the note. update_run reads none of
the run's points.
Run kinds and targets¶
A run has a kind (training by default, eval for evaluations, any other word later) and an
optional target: a free-form JSON object naming what it trains or evaluates, such as
{"model": "clf-v2", "prompt": "summarise-v3", "endpoint": "...", "git": "4f2c1ab"}. Both are set
when the run is created, can be filtered (kind:eval target.model=clf-v2) and are shown in the runs
table once a repository holds more than training runs.
Both are columns of kv0's run table; Run::kind() and Run::target() read them. A string
target is stored as {"ref": "..."}. Reserved parameters under __run__ keep only the SDK's
record of where a run came from (the schedule run that started it).
Deleting runs¶
Store::delete_runs removes runs with their parameters, series, points, statistics and per-run
records (evaluation results, labels and feature samples). The web app asks for confirmation, then
hides the runs and waits eight seconds, with Undo, before it sends the request. Running runs are
refused, in the API and in the app: their script would keep writing to them, so they must finish
or be aborted first.
Deleting is not erasing: older storage versions still hold the data. vigily runs purge (command
line only) erases deleted runs through kv0's purge; the storage guide lists what it does not reach.
Compaction¶
Every finished run commits a few small files per table. A repository of 2 000 runs logged one after
another holds about 2 000 fragments per table, and every scan opens all of them: the first catalog
refresh took 250 to 390 seconds on the scale test below, against 10 seconds after optimize.
kv0's tracking layer now compacts small files itself (kv0 item 9), and the catalog reads no points,
so start-up takes a quarter of a second on that repository without any compaction. vigily up
therefore does not optimize before its first refresh (--optimize-at-start does; it costs about
3 s and takes memory from 110 MB to about 800 MB at once); it optimizes every 15 minutes in the
background (--optimize-every, --no-optimize) and returns the freed memory afterwards. The
measurements are in docs/architecture.md, "Memory".
kv0's compaction does not conflict with concurrent appends, so training keeps logging meanwhile.
The catalog¶
The runs table filters and sorts on parameters and on the last, minimum and maximum of every metric,
on every keystroke. That cannot touch storage. Catalog keeps an in-memory index of every run: its
metadata, flattened parameters and a Summary (count, first and last step, last, minimum, maximum
and where they happened, non-finite count) for every series.
A refresh runs every 2 seconds and before requests when the index is older than one second. It reads each table at most once, in bulk:
- It first asks the store for the versions of its four tables (runs, parameters, series, metrics), which kv0 answers without scanning. A table whose version did not change is not read again; when none changed, the refresh costs four version lookups.
- Otherwise it reads every run, all parameters, all series and the stored series statistics (count, first and last step, last, minimum and maximum and where they happened, non-finite count) that kv0 updates with every flush. No metric points are read, at start-up or later.
Snapshots are immutable (Arc<Snapshot>), so requests never wait for a refresh, and concurrent
refreshes share one run.
The query language¶
A search field is the fastest filter for people who know what they want, and a small language keeps
it one field rather than a form. Terms are separated by spaces and must all match; or separates
groups; - or not negates:
- bare words search the name, experiment, tags, id and notes;
field op valuecompares, with=,!=,:(contains),<,<=,>,>=;- fields are run fields (
status,exp,tag,name,duration,age,archived), parameters by full key or by their last segment (lrfindshparams.lr), and metrics asloss[val].min; - durations take units (
10m,2h).
A field with several values (tags, a metric in several contexts) matches when any value matches;
!= requires every value to differ. A missing field equals null and differs from everything else.
Errors report the character position, which the UI shows under the field.
Rejected: Python-expression queries. They are powerful but need a Python evaluator (or a sandboxed reimplementation) and are slow to type for the common cases.
Charts¶
Library: uPlot¶
The explorer draws tens to hundreds of runs per chart, several charts at once, with a shared crosshair, zoom and live updates. The options I considered:
| Option | Why not (or why) |
|---|---|
| SVG libraries (Recharts, visx, Nivo) | One DOM node per point or path segment; slow past a few thousand points and heavy to re-render on hover |
| ECharts, Plotly | Canvas, capable, but 300 KB to 1 MB of JavaScript and their own theming and interaction models to undo |
| WebGL (regl, deck.gl) | Fast for millions of points, but vigily never sends millions: the server downsamples to the pixel width, so the GPU buys little and costs text rendering, accessibility and code size |
| uPlot | 50 KB, Canvas 2D, draws ~100 000 points in a few milliseconds, a shared-x data model, cursor sync between charts, bands, log scales, and hooks to draw on top |
uPlot's aligned-data model (one shared x array, null for gaps) shapes the server's chart endpoint.
Downsampling: a min/max envelope on a shared grid¶
POST /api/chart reads a metric for many runs and returns them on one x array, at about two values
per pixel column:
- Exact when the distinct x values of every run in the visible range fit the budget: their union, with gaps.
- Envelope otherwise: the range is cut into one bucket per pixel with two slots each, and every run puts its minimum and maximum in the bucket into the slots, in the order they occurred.
A line through the envelope covers the same pixels as the raw data, so spikes, NaN gaps and divergence stay visible. Rejected: LTTB and averaging, which pick or blend points and can hide the one spike that explains a failed run; per-run M4 (four points per bucket), which does not align runs onto one x array.
Zooming sends the visible range back; the server re-samples it at full resolution, keeping one point beyond each edge so lines run to the frame. Finished series are cached raw in the server (bounded at 8 million points) so zooming does not re-read storage.
Aggregation¶
Grouping by a parameter can collapse each group into a mean with a min–max band. Runs of a group rarely log at the same steps (different batch sizes, evaluation intervals), so each run is first reduced onto the shared grid, its empty slots inside its own range filled by linear interpolation, and only then are the mean, minimum and maximum taken. Without the interpolation the mean jumps between subsets of the group and draws a sawtooth.
Smoothing, outliers, the readout¶
- Smoothing is a debiased EMA (as TensorBoard draws it) applied in the browser to the downsampled line, so the slider is instant. With few runs the raw line stays visible, faint, under the smoothed one.
- Ignore outliers (on by default) fits the y axis to the 2nd to 98th percentile of the values when a far outlier, typically a diverged run, would otherwise flatten every other curve. The outlier still leaves the frame visibly.
- The readout lists every run's value at the cursor, sorted best first (highest for names like accuracy, lowest otherwise). It doubles as the legend, so identity never depends on colour.
Colour¶
One accent and neutrals. Every run is drawn in a neutral grey; the run under the pointer and pinned runs are redrawn on top in the accent; a single-run chart draws its run in the accent. Groups are told apart by line dash (solid, dashed, dotted, dash-dot) and named in the readout. Rejected: a categorical palette. With 30 runs any palette repeats, adjacent hues cannot be told apart by colour-blind readers, and the run you care about should be the one that stands out, not the one that drew the brightest hue.
Evaluations¶
Records¶
Evaluations need rows per dataset item, which metric series ((step, value) pairs) cannot hold. They
live in a RecordStore: JSON values under ordered string
keys, written in batches and read by key or prefix. evals.rs owns the key layout (ds/,
dsv/, dsi/, res/, lab/, feat/, sch/, alert/, baseline/); nothing else builds keys.
The production backend is kv0's records tables (<repo>/records/<table>.lance), next to the
tracking tables. Each table is its own Lance dataset with its own versions and tags, chosen by
lifecycle: one per dataset (dataset.<name>, its items and versions), and one per other key prefix
(res, lab, feat, sch, schout, alert, ds, ...). The whole key is the item id; results,
labels and feature samples also carry their run id, so a run's records are read through kv0's
run-id index and deleted with the run. Rows are appended, so many processes can write; the newest
row of a key wins and a null row deletes it, and kv0 drops superseded rows when it optimizes.
Nothing is held in memory. Rows of one table are committed together; a batch that spans tables is
committed table by table in order, so callers write referenced rows (items, outputs) before the
rows that point at them.
Giving each dataset its own table matters for audit tags: a tag keeps one table's version through cleanup and purge, so tagging a dataset version keeps only that dataset's history, never other runs' results, and never blocks erasing a deleted run.
Repositories from before this layout kept every record in one shared table (records.lance);
vigily copies it into the new tables once on open and writes a marker (meta), and leaves the old
table for the operator to delete.
Datasets and versions¶
A dataset version is immutable and content-addressed. An item's hash (SHA-256, 16 hex digits) covers
its input, expected output and metadata in canonical JSON; items are stored once per hash and shared
between versions. A version lists its item ids and hashes in order, and its digest hashes that list.
Committing the same items again returns the existing version. Each version also records the Lance
version of the record store after its commit and tags that version (ds-<name>-<hash>-v<n>), so
version cleanup keeps it and an audit record can cite exactly what was stored when it was evaluated.
Rejected: using Lance versions alone as dataset versions. A Lance version covers the whole records dataset (every dataset, result and label), so it says nothing about one dataset changing; content hashes do, and they survive compaction and copying.
Evaluators and targets¶
An evaluator scores one output against one item and returns a value, a pass or fail, and a reason.
Built-ins are versioned by vigily (vigily/exact_match@1); a custom evaluator's version is the
one given, or a hash of its source, so editing a judge prompt inside the function changes the version
recorded with every score. An LLM-as-judge is user code that calls the user's model client: the
server never holds model API keys and never runs inference.
evaluate() runs in the user's process, writes each item's output, error, latency and scores as
records, and tracks the mean of each score as the metric eval/<score>, so the runs table, the
explorer and drift charts work on evaluations unchanged. An eval run is an ordinary run with
kind = "eval" and a target reference.
Schedules¶
The server runs commands from its configuration file on cron expressions in a time zone (stored and
compared in UTC). The scheduler never overlaps a schedule with itself, applies a missed-run policy
after downtime (skip, or run_once for the latest missed fire), retries, kills a timed-out
command's whole process group, and keeps the last 64 KiB of stdout and stderr. The command gets
VIGILY_SCHEDULE* variables, so runs it logs link back to the schedule run.
Security model. Commands run as the user that runs vigily, with its permissions and
environment. They come only from the configuration file, written by the operator; the API and the web
app can read schedules and their output but cannot create, change or start them. Starting a schedule
by hand is a shell command (vigily schedules run NAME), so it needs shell access.
Leftover processes. A command runs in its own session. When it exits or times out, its process
group and, on Linux, any process that still carries the attempt's VIGILY_SCHEDULE_RUN (which
catches processes that called setsid) get SIGTERM, then SIGKILL two seconds later; output readers
stop two seconds after that. The group leader is not reaped until then (waitid with WNOWAIT),
so neither its process id nor its group id can be reused by an unrelated process in between, and
attempt ids carry a random part and are matched together with the schedule name. Commands must not start long-lived background processes. A process
that both starts a new session and clears its environment can still escape; containing that needs
cgroups, which vigily does not manage.
Retention. Each schedule keeps its newest keep_runs runs (200) younger than keep_days (90)
and the output of the newest keep_output (50). Output lives under separate keys, so listings do not
load it. Runs left running by a server that stopped are marked interrupted at the next start.
Drift¶
A suite is the eval runs that share an experiment, a target reference and a dataset name. Behaviour drift compares one run's per-item scores with its baseline runs', item by item:
- pairing: items match by id and content hash; each item's score in the run is paired with the same item's mean over the baseline runs. The baseline runs repeat the same items, so pooling their scores as independent samples (as the first version did) overstated the baseline's precision and inflated z by about 1.3 for a 7-run baseline;
- test: Wilcoxon signed-rank on the per-item differences, two-sided, zeros dropped, with tie and continuity corrections (checked against scipy);
- effect: the mean per-item difference with a 95 % bootstrap interval that resamples items;
- fallback: with fewer than 10 matched items (the dataset changed), a Mann–Whitney U test of the run's scores against the baseline's per-item means;
- decision: drift when the run is worse, the difference is at least
min_effect(0.02) andp < alpha(0.01); - regressions: the items (same id and content) that passed in the last baseline run and fail now.
The baseline is either pinned runs or the last 7 runs that did not drift themselves. Without that exclusion a lasting regression becomes the baseline within a week and stops being reported; with it, a regression keeps being reported until someone fixes the target or pins a new baseline. This needs a forward pass over a suite's history, which also gives the chart its per-run verdicts.
Non-finite scores are left out. Data drift compares a run's sampled input features (up to 5 000 values each) with its baseline runs'
pooled samples: numeric features by PSI on baseline deciles (stable < 0.1 ≤ moderate < 0.2 ≤ drift)
with a Kolmogorov–Smirnov test (asymptotic p-value) for reference, categorical features by Jensen–Shannon divergence
(base 2; stable < 0.05 ≤ moderate < 0.1 ≤ drift) with a chi-squared test for reference. With
thousands of samples every test is significant, so effect sizes decide. All statistics are
implemented in stats.rs and tested against reference values,
so every number in the UI traces to one formula.
Alerts and the audit trail¶
The server checks each suite's newest run every 30 seconds, and again whenever its number of stored
results or feature samples changes (an evaluating process may still be writing them). It raises an
alert once per drifted run and score or feature, and once per scheduled run whose last attempt
failed. A failure in one suite is logged and does not stop the others. evaluate() writes all
results and features to storage before it marks the run finished. Webhook deliveries are retried
twice, do not follow redirects, and record the outcome without overwriting an acknowledgement. Alerts appear in the web
app and, when alerts.webhook is set, are posted there as JSON.
Every eval result traces back to its dataset version and digest, item hash, target reference,
evaluator name and version, schedule and schedule run, and times in UTC. The run page shows this and
exports it as one JSON document (GET /api/runs/{id}/audit). This supports record keeping for
monitoring deployed models (for example post-market monitoring under the EU AI Act); vigily makes no
claim that using it satisfies any legal requirement.
LLM tracing¶
Data model¶
A trace is a tree of spans sharing a trace id. A span has a span id, a parent id, a name, a kind
(llm, tool, retrieval, chain, embedding, agent or another short word), start and end
times in UTC microseconds, a status (ok, error, unset), an error message, and JSON
attributes; a model call also has a model, a provider, an input, an output, token usage (input,
output and cached input tokens) and a cost. A trace optionally belongs to a run and to an
evaluation item, and to a project (its own project, else its run's experiment) that selects the
payload policy.
Ingest through the server¶
Unlike metrics, spans are sent to vigily up (POST /api/traces/ingest, or OTLP/HTTP JSON at
POST /v1/traces) rather than written to storage by the SDK:
- the payload policy protects personal data, so it must be the operator's (
vigily.toml), applied in one place, not a setting each application could forget; - prices are the operator's too, and cost is computed once, at ingest, and stored with its currency;
- traces come from applications and services, which should not need storage credentials, and an OpenTelemetry exporter can only speak HTTP.
The SDK queues finished spans and a background thread sends batches (256 spans or one second), so a
traced call never waits on the network; an atexit hook sends what is queued. When the server is
down, batches are retried twice and dropped with one warning: tracing must never break the
application. Rejected: the SDK writing spans to storage itself, as it does metrics, for the reasons
above; and OTLP protobuf, which needs a protobuf dependency for little gain (exporters can send
JSON).
Storage layout¶
Two kv0 records tables:
trace: one summary row per trace (trace/<id>): name, times, status, span and error counts, token totals, cost and currency, models, payload mode, run, evaluation item, project. The traces list reads this table only, cached until its version changes.tspan: the spans, one row per ingested batch (tspan/<id>/<n>), so a batch is appended, never rewritten. The trace page reads a trace's chunks by key (the summary counts them).
Both rows carry the trace's run as their kv0 run id, so kv0's run index lists a run's traces and
delete_runs and purge remove them with the run; keys do not name the run, so a trace is found by
its id alone. The server serialises ingests (one lock), because a batch reads and rewrites its
trace's summary. A third table, tsid, lists each trace's stored span ids, so a span sent twice (a
retried batch) is counted once without the summary growing with the trace. When a later batch first
names the run, the rows stored before are rewritten under it (the old rows are superseded, and
erased by the compaction vigily runs purge runs first). One row per span would make the id list
unnecessary and let the page read any span by key, but needs reads of several item ids or an
item-id prefix in one query (kv0 item 23).
Until a trace's project is known (no project and no run named yet, typical for OpenTelemetry
children that arrive before their root), the strictest configured payload policy applies, so a
project's stricter policy can never be undercut by spans that arrived early. The ingest endpoints
accept only application/json and refuse foreign Origins: a web page can send text/plain
POSTs to localhost without a preflight, and would otherwise plant spans.
Payload policy and cost¶
full, truncated (the default: each input, output, error message and string attribute cut to
4 KiB) or metadata (no inputs, outputs or string attributes; error messages keep their type),
globally and per project, applied at ingest and stored with each span so the trace page can say what
was kept. Truncation keeps the default useful for debugging while bounding what is kept; it is not
anonymisation, which the docs say plainly. The SDK's redaction hook runs before a span leaves the
process; if it raises, payloads are dropped rather than sent. Cost is (input − cached) × input +
cached × cached_input + output × output per million tokens, from exact model names or prefix*
patterns. There is no built-in price list: prices change and differ by contract.
Web app¶
React 19, Vite, Tailwind v4 with every colour a token in both themes, Radix primitives for dialogs, popovers, tabs and tooltips, cmdk for the palette, TanStack Query for data and TanStack Virtual for the runs table. The font (Recursive, Sans and Mono from one variable file) is bundled with fontsource: no CDN, so the app works offline and air-gapped. The theme class is set before first paint.
State lives in the URL (filter, sort, columns, chosen metrics, chart settings), so every view can be
shared and Back restores it. The selected runs are kept per browser tab. Everything the pointer can
do the keyboard can: /, j, k, x, c, Enter on runs; arrows, Home, End and Escape
on a focused chart; Ctrl K everywhere.
Server¶
axum on Tokio. The API is read-mostly JSON; responses are gzip-compressed. Series ids are 64-bit and
travel as strings (JavaScript numbers lose them). vigily up binds to 127.0.0.1 and rejects
requests whose Host is not a loopback name, so a DNS-rebinding page cannot read runs. There is no
authentication: vigily is a single-user tool, and a shared deployment belongs behind the
operator's own proxy.
Python SDK¶
A pyo3 extension (abi3, Python 3.11+) under a thin Python layer. Values cross the boundary as JSON
text; contexts are cached per distinct dict, so track costs one Rust call. The GIL is released
around every call that can do I/O. The SDK records the environment (Python, platform, CPU count,
vigily version, git branch and commit) under the reserved parameter __system__; it records no
user names, host names or paths. Metrics named __system__/... are shown on the run's System tab.
Measured¶
On a 20-core Linux workstation with a local SSD:
examples/demo.py: 43 runs, about 1 million points, logged in 3 to 12 seconds.- On the demo (debug build), the runs query answers in about 9 ms.
- 2 000 runs with 4.8 million points (
bench/scale.py, release build, kv0 with its tracking additions): logged one after another in 107 s; start-up 0.27 s (3.41 s when optimizing first, which the server no longer does); the runs table 7 ms, a filtered and sorted query 3 to 5 ms, a 200-run chart 61 to 103 ms cold and 6 to 17 ms cached or zoomed; 244 MB resident (about 1 GB right after a full optimize). Details in docs/architecture.md. Before kv0's tracking additions the same test took 250 to 386 s to start (10.4 s compacted) and 27 minutes to log.
Project decisions¶
- MIT licence.
- Port 7600 by default.
- kv0 is a git dependency on its
mainbranch, pinned byCargo.lock, rather than a path to a sibling checkout: a clone builds with no extra steps and the pinned commit is reviewable. Local co-development uses a[patch]override (CONTRIBUTING.md). I built the kv0 features listed indocs/kv0-requests.mdon kv0'stracking-v2branch and merged them intomain. - TypeScript 6.0 rather than 7: typescript-eslint does not support 7 yet.