expman-rs

High-performance ML experiment manager in Rust
Generated on August 19, 2026

Table of Contents

  1. Architecture
  2. Decisions Log
Reference
  1. CLI Reference
  2. HTTP API Reference
  3. Python API Reference
  4. Storage Layout Reference
  5. Module Map Reference
How-To Guides
  1. Development Setup
  2. Building and Running
  3. Testing and Linting
  4. Adding an API Endpoint
  5. Cutting a Release

Architecture

πŸ”—Architecture

Last verified 2026-07-27 against v0.5.3 + uncommitted TensorBoard work.

πŸ”—The shape of the thing

One crate, expman, with default = [] and three opt-in features. Nothing is built unless asked for:

featurepulls ingives you
cliclap, comfy-table, indicatif, tboard, tensorboard-rsthe exp binary (src/main.rs, src/cli/)
serveraxum, tower-http, rust-embed, mime_guess, tokio-util, futures-utilsrc/api/ and the embedded frontend
pythonpyo3 (extension-module, abi3-py39)src/wrappers/python/ β€” the PyO3 module

Plus a fourth, orthogonal axis: target arch. src/app/ (the Leptos frontend) compiles only for wasm32-unknown-unknown; src/core/engine.rs and src/core/storage.rs compile only for not wasm32. See src/lib.rs:6-18 and src/core/mod.rs:9-19.

The wasm boundary is why src/app/models.rs exists: the frontend can see core::models and core::error but not the engine, so it re-declares its own mirror types for JSON deserialization. These two model sets can drift β€” e.g. app::models::Run.started_at is a String where the server sends a serialized DateTime<Utc>. That drift is a standing hazard, accepted for now because sharing the types would drag serde-compatible variants across the boundary for little gain.

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  Python training   β”‚  expman (PyPI: expman-rs)   β”‚
  loop  ───────────▢│  Experiment.log_vector()    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            PyO3, no GIL release
                                   β–Ό
  Rust training     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  loop  ───────────▢│  core::LoggingEngine        β”‚
                    β”‚  unbounded mpsc channel     β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β–Ό  (dedicated 1-worker tokio runtime)
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  background_task            β”‚
                    β”‚  batches β†’ core::storage    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β–Ό
                    <base_dir>/<experiment>/<run>/
                      vectors.parquet, run.yaml,
                      config.yaml, run.log, artifacts/
                                   β–²
                                   β”‚ reads (no cache)
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  api:: axum server          │──SSE──▢  Leptos WASM
                    β”‚  + rust-embed'd dist/       │◀─fetch─  frontend
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”—The logging engine β€” why it is shaped this way

src/core/engine.rs. LoggingEngine holds only a channel sender, an Arc<Runtime>, and the config (engine.rs:55-60). Every file handle and buffer lives inside the spawned background_task (engine.rs:218). Consequences:

  • log_vector cannot block. It builds a VectorRow and does a synchronous UnboundedSender::send (engine.rs:137-141). No lock on a file, no await, no fsync. That is the whole basis of the "~100ns, never blocks your training loop" claim.
  • The engine builds its own runtime β€” worker_threads(1), thread name expman-io (engine.rs:100-107) β€” because it must work from plain sync Rust and from Python, where there is no ambient tokio runtime to borrow.
  • The channel is unbounded, so a fast producer applies no backpressure and the writer can fall arbitrarily behind. Combined with flush_vectors clearing its buffer even when the write errored (engine.rs:342-345), metric loss is silent. This is a deliberate latency-over-durability trade.
  • All write-path methods are fire-and-forget (let _ = send(...)). Logging to a closed engine is a silent no-op. Only flush() β€” the one async method β€” can report failure.

πŸ”—Batching

Task-local state at engine.rs:230-235. Three independent flush triggers:

  1. vector_buffer.len() >= flush_interval_rows (default 50)
  2. a tokio::time::interval every flush_interval_ms (default 500 ms), which also rewrites run.yaml with the current scalar/vector summary
  3. log_lines.len() >= 20 (hardcoded, engine.rs:279)

The select loop is biased; (engine.rs:242) so draining commands always wins over the flush tick.

πŸ”—Lifecycle

run.yaml is written eagerly with status: Running at construction (engine.rs:74-83). close(status) blocks the calling thread on a oneshot with no timeout (engine.rs:188); Drop does a best-effort shutdown as Finished with a 5 s timeout (engine.rs:197-214). That asymmetry is unintentional but harmless in practice.

One real trap: [profile.release] panic = "abort" (Cargo.toml:112) means Drop does not run on a release-mode panic, so the run stays RUNNING on disk. Conversely, an unwinding debug-mode panic records FINISHED, not CRASHED. A run only reads back as CRASHED when run.yaml is missing or unparseable, via minimal_run_metadata (storage.rs:193-212).

πŸ”—Storage β€” the honest version

src/core/storage.rs. The design is documented in its own header comment (:224-227) as read β†’ concat β†’ write back the whole file. There is no true append, no compaction, no rotation.

  • append_vectors (:228-300) reads the existing Parquet, unions schemas via merge_schemas/align_batch (:404-446) so a metric first logged at step 500 can coexist with earlier rows (missing columns back-fill as typed nulls), then rewrites the file with fs::File::create + SNAPPY.
  • Cost is O(total rows) per flush β€” quadratic over a run. And the write is non-atomic: no temp-file-and-rename, so a crash mid-write truncates the run's entire metric history.
  • Every read path loads the whole file into memory, including read_vectors_since (:312-330), which filters in memory after reading everything β€” and that is what the SSE endpoint calls every 500 ms.

The header comment names Arrow IPC columnar append as the intended fix.

πŸ”—Schema

Inferred per batch, not fixed (rows_to_record_batch, :448-535):

  • step β†’ Int64, nullable
  • timestamp β†’ Timestamp(Microsecond, "UTC"), non-nullable
  • one column per metric key, in first-seen order; type from the first non-null occurrence. Float/Int both widen to Float64; everything else becomes Utf8.

Known defect: :272 downcasts the existing step column to UInt64Array while :475/:486 write it as Int64. The downcast always returns None, the else at :286 keeps the existing batch unfiltered, and cross-batch step dedup (:270-291) is dead code. Re-logging the same step in two different flush batches produces duplicate rows. Verified 2026-07-27.

πŸ”—The server

src/api/. Routes nested under /api (mod.rs:153); everything else falls through to the embedded frontend (mod.rs:155). CORS is fully permissive β€” Any origin/method/header (mod.rs:146-149), appropriate for a localhost tool and a liability anywhere else.

AppState (state.rs:8) is Clone, cloned per request, and holds Arc<PathBuf> base_dir plus two process managers and a CancellationToken. The managers wrap Arc<Mutex<HashMap<String, Instance>>> using std::sync::Mutex, never held across an .await β€” deliberate, since every critical section is a short map operation.

There is no caching at any layer. Every request re-reads the filesystem and re-parses Parquet. Fine for a single-user localhost dashboard; the first thing to change if that assumption ever breaks.

Realtime is SSE only β€” two endpoints, both 500 ms poll loops with 15 s keep-alives (metrics.rs:41, :78), terminated by .take_until(shutdown.cancelled()). Axum's ws feature is enabled in Cargo.toml but no WebSocket endpoint exists.

πŸ”—Spawned-process services

jupyter_service.rs and (uncommitted) tensorboard_service.rs follow one pattern: detect the tool by shelling out to --version, pick a free port by scanning a range (8888–9999 for Jupyter, 6006–7999 for TensorBoard), spawn the child with auth and framing protections disabled, register it in the map keyed "{exp}:{run}", and reap it on status/stop/shutdown_all.

Jupyter's launch command is configuration, not a constant (1.3.0). exp serve --jupyter-command is a command line β€” shell-word-split, no shell β€” that the notebook --no-browser --port=… arguments are appended to, so uv run --extra nb jupyter composes. This is the whole kernel story: the kernel a notebook gets is the interpreter Jupyter runs under, so launching Jupyter from inside the project's environment makes the project's package importable with no ipykernel install and no kernelspec for expman to keep in sync. Nothing is written to ~/.local/share/jupyter, by design.

The generated notebook is a rendered template with a provenance stamp. The content comes from --notebook-template, else <base_dir>/.expman/notebook.ipynb, else a built-in default; {{run_dir}}-style placeholders are substituted with JSON-escaped values, and the result is parsed before being written so a bad template degrades to the built-in rather than to a corrupt .ipynb. Each write records metadata.expman.{template_hash, content_hash}, which is what lets a later launch tell "expman wrote this and the template has since changed" (rewrite) from "the user changed this" (never touch). Rules and rationale: CLI reference.

No HTTP proxying. The browser connects directly to http://localhost:{port} (interactive.rs:120,210; tensorboard.rs:101,149). That is why Jupyter is launched with --ServerApp.token='' --ServerApp.password='' --disable_check_xsrf and a loosened CSP. It also means the dashboard only works when viewed from the machine running the server β€” a real limitation, and the reason a proxy route is the obvious next architectural step.

πŸ”—The frontend

src/app/, Leptos 0.8 CSR, built by trunk from src/app/index.html into dist/, embedded via rust-embed (api/frontend.rs:9-15), with the SPA fallback decided by is_asset_request: a path whose last segment ends in a known asset extension (ASSET_EXTENSIONS) 404s when it is not in the bundle; everything else gets index.html and is routed client-side.

The rule used to be "a path containing a . is an asset", which broke every experiment named after a dm_control task β€” /experiments/dmc-cartpole.swingup 404'd, so the dashboard could not be deep-linked to one. The extension list is deliberately wider than what the bundle embeds: serving index.html for a missing .js hands the browser HTML where it expected JavaScript, and the resulting syntax error names the wrong file. html is excluded, because answering an HTML request with the shell is exactly what the fallback is for.

  • Data fetching is plain gloo_net returning Result<T, String>, wrapped in LocalResource + <Suspense>. Metrics do not auto-refresh β€” only logs are live (SSE); charts have manual refresh buttons.
  • Charts are plotters + plotters-canvas with hand-rolled pan/zoom, including a manual Liang–Barsky clipper in utils.rs:16-100 because CanvasBackend does not clip.
  • Styling is Tailwind from a CDN, hardcoded in src/app/index.html:8 β€” along with a Google Fonts link. stylist is declared in Cargo.toml:91 and used nowhere in src/. The practical consequence: the "single self-contained binary" story breaks offline, because the dashboard renders unstyled without internet. Verified 2026-07-27.

πŸ”—The build dance

build.rs is the riskiest file in the repo for release engineering. When the server feature is on and the target is not wasm32, it shells out to trunk build --release (with CARGO_TARGET_DIR=target/wasm_build and MAKEFLAGS stripped to avoid recursive-cargo deadlock). On failure it falls back to an existing dist/index.html, writes placeholder assets during CARGO_DOC, and otherwise hard exit(1)s (build.rs:44).

Every escape hatch in the repo exists to route around that one branch: EXPMAN_SKIP_FRONTEND_BUILD, the CARGO_DOC placeholder path, just prep-dist, include = ["dist/**/*"] in Cargo.toml, cargo publish --allow-dirty, and CI downloading a frontend-dist artifact into dist/ before every Rust job. See how-to/release.

πŸ”—Python bridge

src/wrappers/python/mod.rs. A #[pyclass] Experiment holding Arc<Mutex<Option<LoggingEngine>>> β€” the Option is the closed-sentinel, so a double close is a no-op.

There is no GIL management anywhere β€” no allow_threads, no Python::with_gil, no py.detach(). The design relies on every logging method being bounded and non-blocking while holding the GIL: convert the dict, take an uncontended mutex, send on the channel. That is a load-bearing assumption, not an enforced invariant; if the send ever blocked, it would stall every Python thread.

Two type-coercion helpers with deliberately different ordering:

  • py_dict_to_map β†’ MetricValue, tries f64 first (:254-264). Since Python bool and int both extract as f64, True and 1 both become Float(1.0) β€” the Int/Bool arms are effectively dead for metrics.
  • py_dict_to_yaml β†’ serde_yaml::Value, tries bool β†’ i64 β†’ f64 (:274-284), so params preserve their types faithfully.

The asymmetry is intentional (metrics are numeric vectors, params are configuration) but surprising if you only read one of the two.

Also note: all write-path errors are swallowed, close() maps any unrecognized status string silently to Finished (:194), and panic = "abort" means a Rust panic aborts the whole Python process rather than raising.

πŸ”—Related

  • Data model and on-disk layout: reference/storage-layout
  • Why each of these choices was made, dated: decisions

Decisions Log

πŸ”—Decisions

Append-only log of significant decisions. Dates are the date the decision became visible in the repo (commit date), not necessarily when it was made. Reconstructed from source and git history on 2026-07-27 β€” entries before that date are inferred, so rationale marked "inferred" was not written down at the time.


πŸ”—Unbounded channel + fire-and-forget logging

Decision. LoggingEngine's command channel is mpsc::unbounded_channel, and every write-path method discards send errors (let _ = ...).

Why. The product claim is that log_vector() never perturbs a training loop. A bounded channel would either block the caller or force an async API; either breaks the claim. (Inferred from src/core/engine.rs:5-7 doc comment.)

Consequences we accept. No backpressure β€” a fast producer can push the writer arbitrarily behind. flush_vectors clears its buffer even when the write failed (engine.rs:342-345), so metric loss is silent. Logging to a closed engine silently no-ops. Only flush() can report an error.


πŸ”—Read-concat-rewrite instead of true Parquet append

Decision. Each flush reads the whole vectors.parquet, concatenates the new batch, and rewrites the file (src/core/storage.rs:228-300).

Why. Parquet has no append primitive, and per-flush row groups would fragment the file. The header comment (storage.rs:224-227) states the intent and names Arrow IPC columnar append as the eventual fix.

Consequences we accept. O(total rows) per flush β†’ quadratic over a long run. The write is non-atomic (fs::File::create, no temp-and-rename), so a crash mid-write destroys the run's whole metric history. Every read path loads the entire file into memory, including the 500 ms SSE poll.


πŸ”—Per-batch inferred schema with diagonal concat

Decision. The Arrow schema is derived from each batch's keys rather than declared up front; merge_schemas + align_batch union the fields and back-fill missing columns with typed nulls (storage.rs:404-446).

Why. Training code adds metrics mid-run (a validation metric that first appears at step 500 must coexist with earlier training-only rows).

Consequences. Type is fixed by the first non-null occurrence of a key. Float and Int both widen to Float64; anything else becomes Utf8.


πŸ”—Dedicated single-worker tokio runtime inside the engine

Decision. LoggingEngine::new builds its own worker_threads(1) runtime named expman-io rather than requiring an ambient one (engine.rs:100-107).

Why. The engine is called from plain sync Rust and from Python, neither of which has a tokio runtime to borrow.


πŸ”—No GIL release in the PyO3 bridge

Decision. No allow_threads / Python::with_gil / py.detach() anywhere in src/wrappers/python/mod.rs.

Why. Every logging method is bounded and non-blocking β€” dict conversion, an uncontended mutex, a channel send. Releasing and re-acquiring the GIL around that would cost more than the work itself.

Consequence. Load-bearing assumption, not an enforced invariant. If the send ever became blocking, it would stall all Python threads.


πŸ”—Metrics float-flatten; params keep their types

Decision. py_dict_to_map tries f64 first (mod.rs:254-264), so Python True and 1 both land as MetricValue::Float(1.0). py_dict_to_yaml tries bool β†’ i64 β†’ f64 (:274-284), preserving types.

Why. Metrics are numeric time series; hyperparameters are configuration that must round-trip faithfully into config.yaml.

Consequence. The Int/Bool arms of MetricValue are effectively dead for Python-sourced metrics. Exercised by wrappers/python/tests/test_experiment.py::test_vectors_vs_scalar.


πŸ”—Spawn-and-iframe rather than proxy, for Jupyter and TensorBoard

Decision. The server spawns jupyter notebook / tensorboard as child processes on a scanned free port, and the browser connects directly to http://localhost:{port}. No HTTP proxying through the axum server.

Why. Proxying Jupyter (websockets, XSRF, kernel protocol) is substantial work; direct iframe was the cheap path. (Inferred.)

Consequences we accept. Jupyter runs with --ServerApp.token='' --ServerApp.password='' --disable_check_xsrf=True and a loosened CSP (jupyter_service.rs:305-315). The dashboard therefore only works when viewed from the machine running the server. A proxy route is the obvious next architectural step. The uncommitted TensorBoard service repeats the pattern and additionally uses --bind_all (0.0.0.0, no auth) β€” see STATUS.md.


πŸ”—SSE, not WebSockets

Decision. Realtime uses two Server-Sent Events endpoints, each a 500 ms poll loop (src/api/metrics.rs:41, :78).

Why. One-directional server→client streaming with automatic browser reconnection, no protocol upgrade to manage. (Inferred.)

Note. Axum's ws feature is enabled in Cargo.toml:71 but no WebSocket endpoint exists. The feature is dead weight.


πŸ”—Tailwind via CDN β€” superseded 2026-07-28

Reversed. Both the Tailwind CDN and the Google Fonts link are gone; see "Type is self-hosted and assigned by role" and "Tailwind is built, not fetched from a CDN" below. Kept for the record.

Decision. src/app/index.html:8 loads https://cdn.tailwindcss.com and a Google Fonts stylesheet at runtime. stylist is a declared dependency (Cargo.toml:91) that is used nowhere.

Consequence. The "single self-contained binary" story is not actually self-contained: the dashboard renders unstyled without internet. Verified 2026-07-27. Either vendor Tailwind into dist/ or drop the claim.


πŸ”—2026-07 (49c24ca) β€” ship dist/ inside the published crate

Decision. Cargo.toml gained an explicit include = [...] list containing "dist/**/*".

Why. dist/ is in .gitignore, so cargo package was omitting the built frontend. Consumers building with --features server then hit build.rs's hard exit(1).


πŸ”—2026-07 (d423ff8) β€” --allow-dirty and the CARGO_DOC escape hatch

Decision. Four changes in one commit:

  1. publish-cargo.yml:29 gained --allow-dirty. Direct consequence of the above: CI downloads frontend-dist into dist/, which is both git-ignored and in Cargo's include, and cargo refuses to package files it will ship but that are not committed.
  2. Reverted the previous commit's prep-dist change β€” touch dist/index.html is back (Justfile:37-38).
  3. build.rs gained a CARGO_DOC branch (:12, :30-40) writing placeholder dist/ assets instead of exit(1), unblocking docs.yml.
  4. Riders: .ipynb_checkpoints/ filtering in storage.rs:36,59, and tensorboard.py switching to redirect_console=True with eager log_params({}) / info(...) so files appear immediately.

Read 49c24ca and d423ff8 together β€” the second exists because of the first.


πŸ”—2026-07 β€” Virtual projects layer via .projects metadata

Decision. Experiments can be assigned to a project via a project field in experiment.yaml. Projects are stored as metadata in <base_dir>/.projects/<project_name>/project.yaml and README.md (markdown frontpage). list_experiments ignores all dot-directories (such as .projects and .ipynb_checkpoints).

Why. Avoids altering the flat directory layout of existing experiments (<base_dir>/<experiment>/<run>), maintaining full backwards compatibility. Experiments without a project remain unassigned in the main listing by default.

Consequences. Non-breaking file layout. Project deletion unassigns member experiments without touching their directory contents or runs. Client-side Markdown rendering via pulldown-cmark in WASM provides project frontpages with live browser editing.


πŸ”—2026-07-28 β€” The projects layer must be reachable without a server

Decision. Every project operation exists offline: Experiment(project=...), Experiment.set_project(), expman.assign_project(), and exp project ls/new/show/assign/unassign/rm/sync. All of them write only the project: field of experiment.yaml.

Why. The layer was HTTP-only, so a compute node running a SLURM batch job β€” the case it exists for β€” could not use it. A feature reachable only from the dashboard is not usable by the runs it is meant to organise.

Consequences. experiment.yaml is still written only when absent, but an explicit project= on a later run now updates that one field rather than being silently ignored. Nothing else in the file is touched, so display name, description and tags survive.


πŸ”—2026-07-28 β€” A generated project is read-only, and says so

Decision. exp project sync projects a YAML manifest into the store and marks the result generated: true, with generated_from naming the authority. The HTTP API refuses PATCH/PUT/DELETE on such a project with 409 generated_project; the dashboard hides its edit affordances and shows a banner; the README carries an <!-- expman:generated ... --> first line.

Why. An expman project can be a one-way projection of a source that lives elsewhere (a thesis repo's studies.yaml), carrying only general information and experiment membership. Each sync overwrites it wholesale.

Consequences. Accepting a dashboard edit would report success and then lose the work at the next sync, with no trace. Refusing is the honest failure. Sync also reconciles membership: experiments dropped from the manifest are unassigned, so the projection cannot drift into a superset of its source.


πŸ”—2026-07-28 β€” Liveness is a heartbeat, not an age

Decision. The engine refreshes run.yaml.heartbeat_at every heartbeat_interval_secs (default 30s). exp reap --older-than marks runs CRASHED whose heartbeat has gone stale. /stats splits active_runs from stale_runs.

Why. A hard kill leaves status: RUNNING forever, so active_runs overcounted silently. Reaping on started_at alone would have been simpler but would kill legitimately long multi-day jobs β€” unacceptable for thesis runs.

Consequences. Runs written before heartbeats existed have heartbeat_at: null and fall back to started_at, which only ever reaps them later, never sooner. Reaping is a dry run without --force, and sets finished_at to the last heartbeat rather than to the moment of reaping, so a run reaped a week late does not report a week-long duration. Reaping stays a deliberate command: the server never mutates run.yaml on a read path.


πŸ”—2026-07-28 β€” The read API returns builtins; pandas stays optional

Decision. load_runs, read_metrics, load_config, load_run and load_projects return plain dicts and lists. to_pandas=True imports pandas lazily, at call time.

Why. Without a read API, analysis scripts read Parquet and a side-car JSON manifest directly, which left the manifest β€” not expman β€” as the durable record. Two sources of truth would otherwise be permanent. Making pandas a hard dependency of the wheel would push that cost onto every consumer, including cluster nodes where the install is a constraint.

Consequences. load_runs() rows carry a path that feeds straight into read_metrics(), so callers never do path arithmetic. Tag queries accept both a list (conjunction) and an expression string with AND/OR, shared with the CLI and the HTTP API through storage::parse_tag_expr β€” one grammar, three faces.


πŸ”—2026-07-28 β€” Type is self-hosted and assigned by role

Decision. Three roles: Space Grotesk (display), Nunito (running text), Cascadia Code (labels, run IDs, data). Latin subsets are vendored from Fontsource into assets/fonts/ and embedded in the binary; the Google Fonts CDN link is gone.

Why. The dashboard is distributed as a self-contained binary, so a CDN font made it silently wrong offline and on air-gapped cluster nodes. The third role is wider than "code": anything that is a value rather than a sentence β€” a run ID, a count, a metric, a timestamp, an uppercase eyebrow label β€” is set mono so tabular things align and scan as data.

Consequences. ~273 KB of woff2 in the binary. frontend.rs must include *.woff2 in its rust_embed filter or the faces 404 and fall back silently.


πŸ”—2026-07-28 β€” Tailwind is built, not fetched from a CDN

Decision. data-trunk rel="tailwind-css" builds assets/tailwind.css with the standalone Tailwind CLI, pinned to 3.4.17 in Trunk.toml [tools] and provided as pkgs.tailwindcss_3 for the offline Nix build. The cdn.tailwindcss.com script is gone.

Why. It was the last third-party request. A dashboard shipped as a self-contained binary that renders unstyled without internet is not self-contained. Verified in a browser: zero external resource loads.

Consequences. Two upsides that were not the goal. The stylesheet is tree-shaken from the .rs sources (68 KB, 11 KB gzipped) instead of the CDN's full runtime. And prose now works β€” the dashboard renders project READMEs with typography classes that the plain Play CDN never shipped, so they had been silently inert. The standalone CLI bundles @tailwindcss/typography, so this needs no npm.

Two costs. Tailwind only sees classes it can find in the source, so a class assembled at runtime (format!("text-{}", colour)) will not be generated β€” build them from a match returning whole literals. And the pin must stay equal to pkgs.tailwindcss_3.version; both were verified to emit byte-identical CSS.


πŸ”—2026-07-28 β€” Cargo.toml is the only place the version lives

Decision. wrappers/python/pyproject.toml uses dynamic = ["version"] and flake.nix reads builtins.fromTOML ./Cargo.toml. just bump edits one file. just check-versions asserts no literal has crept back and runs in CI.

Why. The version was duplicated across four files, aligned only by just bump, with nothing validating it. Since expman.__version__ comes from CARGO_PKG_VERSION rather than pyproject.toml, a drift would have shipped a wheel whose metadata disagreed with the module it contained.

Consequences. Verified by bumping Cargo.toml to 9.9.9: the wheel built as expman_rs-9.9.9 and nix eval .#expman.version returned 9.9.9, both without touching another file. check-versions is deliberately POSIX-only so it needs no extra tooling in CI.


πŸ”—2026-07-28 β€” Releases block on tests

Decision. publish.yml calls rust.yml and python.yml itself, and both publish jobs need them.

Why. ci.yml fires on the same push but is a separate workflow, so it could not gate anything. cargo publish and uv publish ran in parallel with the test suite: a red build published anyway, and no job failed to say so.

Consequences. A release now waits for the suite, and a flaky test blocks a release rather than merely reporting. ci.yml still runs on the same commit, so a release commit runs the suite twice in parallel β€” accepted as the cost of keeping both entry points independently meaningful.


πŸ”—2026-07-28 β€” .maturinignore was dead; deleted

Decision. Removed the root .maturinignore.

Why. Its paths lacked the wrappers/ prefix, and maturin resolves the file beside pyproject.toml anyway. Proven rather than assumed: wheels built with and without it have byte-identical file lists, both containing expman/bin/exp.

Consequences. None to the wheel. include = ["expman/bin/exp*"] in pyproject.toml is the actual mechanism, together with expman/bin/ being untracked but not gitignored β€” see the note in STATUS.md, which is the part that genuinely matters.


πŸ”—2026-07-28 β€” One set of wire types for server and frontend

Decision. core/dto.rs holds the HTTP response shapes and is compiled for both native and wasm32. Handlers construct and serialize those types; app/models.rs re-exports them instead of redeclaring them.

Why. The frontend's models were a hand-maintained mirror of serde_json::json! literals in the handlers. Adding a field meant remembering to add it in two places, and it drifted again during this session's work.

Consequences. Three latent bugs surfaced immediately, because typing the contract made them compile errors rather than silent mismatches:

  • runs_table.rs matched run status against "COMPLETED", which this API never emits (it is FINISHED), so every finished run rendered in the grey unknown-status colour instead of green.
  • format_date parsed an RFC 3339 string and fell back to printing the raw string on failure. It now takes a DateTime<Utc>, so there is no parse and no fallback.
  • The Jupyter backend was a stringly-typed comparison against "jupyter" on one side of an enum that serialized lowercase on the other.

Wire types stay separate from storage models on purpose: dto::Run may expose less than RunMetadata, but it does so through a constructor, so removing a storage field is a build error rather than a quietly changed endpoint.


πŸ”—2026-07-29 β€” Metrics are append-only while a run is live

Decision. Each flush appends an Arrow IPC batch to a segment file; segments are folded into vectors.parquet when the run closes. A metric first logged mid-run rolls a new segment, since one IPC stream carries one schema.

Why. Every flush previously rewrote the whole Parquet, so total write volume grew with the square of the step count. Measured: 10k steps took 48.2s before and 0.39s after β€” the same test, 124Γ— less time, nearly all of it in flush and close.

Consequences. Readers must union segments with the Parquet, so read_run_vectors replaces "open vectors.parquet"; a reader that skipped this would show nothing for a live run. Rows sharing a step are merged at read time, preserving the old log_vector(step=1)-twice semantics. Crash behaviour improves: a truncated segment yields every batch fully written, and compaction writes the Parquet before deleting segments, so an interruption leaves both and readers union them to the same result. append_vectors survives for one-shot writes such as exp import.


πŸ”—2026-07-29 β€” run.yaml is mutated only under a lock

Decision. All run-metadata mutation goes through storage::update_run_metadata, which takes an exclusive advisory lock. save_yaml writes atomically via temp-file-and-rename.

Why. DDP puts N ranks in one run directory, each ticking its own metadata update. Load-mutate-save races, and the loser's tags, scalars or final status vanish. Separately, fs::write truncates before writing, so a dashboard polling during the engine's 500ms write could read an empty file and report the run CRASHED.

Consequences. Advisory locks only bind processes that take them, which is exactly the contention here. A regression test runs 8 threads Γ— 10 updates and asserts all 8 tags survive.


πŸ”—2026-07-29 β€” System metrics come from vendor CLIs, not linked SDKs

Decision. Sampling shells out to nvidia-smi, rocm-smi and tpu-info, plus /proc for CPU and memory. Probes are declarative (ProbeSpec) and a user can add one in config without a rebuild.

Why. Chosen over NVML/ROCm bindings: no build-time vendor SDK, no runtime dlopen that fails differently on every cluster image, and hardware we have never seen is a config entry rather than a patch. The cost β€” a subprocess every 15s β€” is irrelevant next to a training step.

Consequences. Exactly two parsers. nvidia-smi is read back from a column list we specified, so parsing is not guessing. Everything else is JSON, flattened by taking every numeric leaf into a dotted key: schema-agnostic, so it works for rocm-smi, for tpu-info, and for a user's own tool, and does not break when a vendor adds a field.

Verified on real hardware, 2026-07-29, and the TPU guess was wrong. tpu-info has no --json at all; it prints Rich pipe tables, one per --metric. That added a third parser, PipeTable, which reads the first column as the device and the remaining headers as metric names β€” and handles several tables in one pass, which is what tpu-info emits for repeated --metric flags.

Two details only real output would have shown: the tables use ASCII | while the surrounding Rich panels use box-drawing characters, so the panels are skipped without needing to be recognised; and hbm_usage/duty_cycle_percent read N/A until a framework opens the TPU, so they are skipped rather than recorded as zero. Recording 0 GiB of HBM during a live job would be a lie.

NVIDIA needed no change: the probe worked first time on a 2Γ— RTX A6000 host, all six metrics per GPU.


πŸ”—2026-07-29 β€” A DDP job is one row, expandable

Decision. group + rank on every run, auto-detected from RANK / SLURM_PROCID / EXPMAN_RANK and the scheduler's job id. The dashboard shows rank 0 with a β–Έ N ranks badge; clicking expands the ranks.

Why. A 4-GPU job is one thing you ran, not four rows of near-identical metrics β€” and a 100-trial sweep on 8 GPUs would otherwise be an 800-row table. Auto-detection matters more than the flag: a DDP script needs no expman-specific change, and ranks get distinct run names so they cannot overwrite each other.

Consequences. is_primary is exposed and returns True on rank 0 or a non-distributed run, because rank == 0 is False when there is no rank β€” the obvious guard is the wrong one.


πŸ”—2026-07-29 β€” Provenance is captured; the diff is opt-in

Decision. Git commit/branch/dirty, command line, hostname and scheduler job ids are captured by default into provenance.yaml. The working-tree diff only with capture_diff=True.

Why. The cheap fields cannot leak anything not already in the repository. A diff can: an edited .env, a pasted key, an unpublished data path. Knowing a run was dirty is cheap and safe; knowing exactly how is the user's call.

Consequences. Provenance::is_reproducible() is explicit that a dirty run without a diff cannot be reconstructed. Scheduler capture takes an allow-list of identifying variables, never a dump of the environment.


πŸ”—2026-07-29 β€” A sweep is a group of runs, with two backends

Decision. exp sweep expands a YAML config into trials that are ordinary runs sharing a group and tagged with their parameters. run executes locally with a concurrency cap; slurm emits an sbatch array.

Why. Reusing groups means no new storage concept and no server. Both backends because a laptop and a cluster are different problems β€” and on a cluster the scheduler is the only thing allowed to place work, so emitting sbatch and stopping beats writing an agent that fights it.

Consequences. Params reach a trial both as {placeholders} in the command and as EXPMAN_PARAM_*, so neither an argparse script nor a shell wrapper needs editing. Random search uses a seeded SplitMix64 rather than rand, so a sweep re-expanded next year yields the same trials β€” something a dependency bump cannot promise. Grid refuses min/max instead of silently sampling. Tagging trials with their params is what makes the Compare tab work at all.


πŸ”—2026-07-29 β€” Media and histograms are stored, and the silent stubs are gone

Decision. log_image, log_figure, log_audio, log_video and log_histogram store natively. The TensorBoard SummaryWriter shim is backed by them, and the four methods that genuinely cannot be stored warn once each.

Why. The shim's add_image/add_histogram/add_figure were silent no-ops. Swapping torch.utils.tensorboard.SummaryWriter for expman's threw away every image and histogram with nothing said at runtime β€” the worst kind of failure, because the code looks like it works.

Consequences. Images go to media/ with a JSONL manifest rather than into Parquet: a column of image blobs would ruin the metrics file for its real purpose, and the dashboard needs a URL anyway. Histograms are one row per (tag, step) with JSON edges/counts, because bin counts vary and a column-per-bin schema would churn on every call.

A deliberate asymmetry: the native API raises on input it cannot encode, while the compat layer warns. New code deserves a hard error; code written for TensorBoard and running for three days does not deserve to die over one unencodable image.


πŸ”—2026-07-29 β€” exp ... | head must not panic

Decision. Restore the default SIGPIPE handler in main.

Why. Rust ignores SIGPIPE, so a closed downstream pipe becomes an EPIPE write error and println! panics on it. exp sweep preview | head printed its output and then a panic with a backtrace.

Consequences. One unix-only libc dependency, used for one call.


πŸ”—2026-07-29 β€” The perf test runs alone

Decision. .config/nextest.toml gives test_log_vector_is_fast threads-required = "num-test-threads", so nextest runs it with the machine to itself.

Why. It asserts a wall-clock budget β€” 10,000 log_vector calls under 100ms. Run in parallel with 70+ other tests, several of which shell out to git, it measures contention rather than the hot path. It failed spuriously twice in one session and passed immediately when re-run alone, which is exactly the shape of a test that erodes trust in the suite.

Consequences. The suite takes marginally longer. The number the test prints is now the number the assertion is about.


πŸ”—2026-07-29 β€” libtpu direct: investigated, rejected

Decision. Keep shelling out to tpu-info. Do not write a native gRPC client against libtpu.

Why. tpu_info reaches libtpu over gRPC at localhost:8431 (RuntimeMetricServiceStub, local channel credentials), so a native client is technically possible. Measured on the v6e host:

  1. ss -ltn shows nothing on 8431 while the TPU is idle. libtpu starts that server inside the training process. A direct client would get connection-refused exactly as often as tpu-info returns N/A β€” so it fixes nothing about the availability gap, which is the only real limitation.
  2. No .proto source ships, only compiled _pb2.py. tonic would mean vendoring a reconstructed, Google-internal, unversioned schema that a libtpu update can break silently.
  3. grpc.local_channel_credentials() has no tonic equivalent.
  4. The saving is ~391ms per sample (of which ~100ms is Python import) against a 15s interval β€” ~2.6% of one core, on a thread that never touches the training loop.

Consequences. Probes now sample concurrently, so a host with several accelerators pays the slowest probe rather than their sum. Revisit only if sub-second sampling is ever wanted β€” for which a profiler, not a sampler, is the right tool.


πŸ”—2026-07-29 β€” Downsample server-side, preserving spikes

Decision. GET /metrics returns at most 2000 points, chosen by Largest-Triangle-Three-Buckets. ?full=1 returns everything; ?max_points=N sets the cap.

Why. A long run has far more steps than a chart has pixels, and serialising a million rows to JSON hangs the tab before it draws.

Consequences. LTTB rather than a stride, because stride sampling drops the single-row loss spike or divergence β€” precisely what someone opens the chart to find. First and last rows are always kept so endpoints are exact. A regression test plants two spikes in 5000 rows and asserts both survive a reduction to 200.


πŸ”—2026-07-29 β€” A memo, not a SQLite index

Decision. load_run_metadata_cached memoises parsed run.yaml keyed on (mtime, len). No index, no new dependency.

Why. The earlier recommendation here was a SQLite index. Profiling changed it: of 149ms to query 800 runs, 136ms was YAML parsing and 3ms was stat. The problem is not lookup, it is re-parsing unchanged files inside one long-lived process. Measured after: 167ms cold β†’ 9.6ms warm.

An index would have been a second copy of the truth that can go stale, needs a migration story, and pulls in a C dependency β€” to solve a problem a memo solves transparently. A cold process simply parses as before.

Consequences. The memo is not used by update_run_metadata: a read-modify-write must see the current file, and mtime granularity is too coarse to trust at the engine's 500ms cadence. A test rewrites run.yaml to a same-length, different-status document and asserts the change is seen.


πŸ”—2026-07-29 β€” build.rs degrades instead of failing

Decision. A missing or failing trunk now emits a cargo:warning naming the cause and writes a placeholder dist/index.html. The build succeeds.

Why. exit(1) meant cargo build --all-features on a clean checkout died at build-script time with a message that never said "install trunk" β€” and every workaround in the repo descended from that branch. A broken frontend is not a reason to be unable to build the CLI.

Consequences. Verified by building --features cli,server with trunk off PATH and no dist/: it warns, succeeds, and the server answers /api/stats normally while serving a placeholder page that says just build-frontend. The CARGO_DOC special case is gone β€” the general path covers it, confirmed by just build-docs.

The hazard this creates is the opposite one: a release built without trunk would silently publish the placeholder. publish-cargo.yml therefore now verifies that dist/ holds a real bundle β€” a .wasm present and the placeholder marker absent β€” and fails the publish otherwise. Trading a build-time hard failure for a release-time one is the right way round: the first blocked every contributor, the second blocks only a genuinely broken release.


πŸ”—2026-07-29 β€” Toolchain moved to 1.97; wasm-bindgen-cli pinned

Decision. nix flake update (fenix and nixpkgs, Feb β†’ Jul 2026), plus flake.nix now takes pkgs.wasm-bindgen-cli_0_2_108 rather than pkgs.wasm-bindgen-cli.

Why the update. Local clippy was 1.93 while CI ran 1.97, and 1.97's unnecessary_sort_by failed the 1.1.0 release while everything was green locally. Local and CI now both report clippy 0.1.97 (8bab26f4f6 2026-07-14).

Why the pin. The same update moved nixpkgs' wasm-bindgen-cli from 0.2.108 to 0.2.121 while the wasm-bindgen crate stayed at 0.2.108 β€” js-sys 0.3.85 pins it exactly, so the crate cannot move alone, and the family can only reach 0.2.126, still not 0.2.121. That mismatch breaks nix build .#expman, which runs TRUNK_OFFLINE=true and therefore uses the Nix-provided binary rather than letting trunk fetch the matching one.

nixpkgs publishes versioned attributes for exactly this, so the fix is one word. Pinning couples flake.nix to Cargo.lock rather than to nixpkgs' packaging cadence, which is the right direction: the crate is ours to choose, nixpkgs' default is not.

Consequences. Changing the wasm-bindgen crate now requires changing the attribute in step; the check is two greps and is written down in the expman-build skill. Caught before it reached CI β€” which is the argument for doing a flake update as its own change rather than inside a release.


πŸ”—2026-07-29 β€” The release check no longer interpolates the commit message

Decision. check-release.yml passes the commit message through env: instead of interpolating it into the script body.

Why. It was written as a bare interpolation inside a run block, which makes the message part of the script text. A message containing backticks or a dollar-paren therefore executes on the runner. That is script injection, and it is reachable by anyone who can land a commit on main.

It fired for real: a commit body of mine mentioning a nix build command in backticks was executed, and the step died with "nix: command not found". Benign only because nix is not installed on the runner β€” a message invoking curl would not have been.

Consequences. The whole release chain skipped, so nothing mispublished, but the Publish workflow went red on a commit that was never meant to release. The fixed version was verified against the injection payloads, the real release subject, and the message that broke it. It also confirms that a release commit must have no body: the anchored regex is matched against the entire message, so a multi-line message never matches.

It is the only such interpolation in the eleven workflows; the rest were checked.


πŸ”—The notebook kernel comes from the launch command, not a registered kernelspec

2026-08-19, 1.3.0.

Decision. exp serve --jupyter-command <CMD> configures the command line that launches Jupyter (default jupyter, i.e. the previous behaviour). expman does not run ipykernel install, and does not write a kernelspec name into the notebooks it generates.

Why. The problem being solved is "the Interactive tab cannot import the project's own package". The kernel a notebook gets is the interpreter Jupyter itself runs under, so launching Jupyter from inside the project's environment β€” --jupyter-command 'uv run --extra nb jupyter' β€” makes the built-in python3 kernel be that environment. There is then nothing to install and no name for expman and the project to keep in sync. The alternatives both write into ~/.local/share/jupyter, which is the user's, and then have to stay correct across every environment rebuild.

Consequences we accept. The value is a command line, so it needs splitting; shlex does it POSIX-style but no shell runs, so pipes, redirection and variable expansion are unavailable. And because expman runs the command with the run directory as cwd (Jupyter has to serve interactive.ipynb from there), a command that discovers its environment from cwd only works when the store lives inside the project β€” otherwise the project must be named, e.g. uv run --project /path/to/project …. Documented in the CLI reference.


πŸ”—Notebooks are rendered from a template and regenerated unless edited

2026-08-19, 1.3.0.

Decision. A run's interactive.ipynb is rendered from --notebook-template, else <base_dir>/.expman/notebook.ipynb, else the built-in default, with {{run_dir}} / {{run_name}} / {{experiment}} / {{store}} / {{project}} substituted. Each write stamps metadata.expman.{template_hash, content_hash}, and a later launch rewrites the file only when it still hashes to content_hash (unedited) and template_hash has changed.

Why. Two separate defects. The cells were hardcoded, so a project could not make the tab do project-specific work β€” the motivating case being a run whose rollout GIF failed to render on a headless training host, where the checkpoint is there and only the notebook can turn it into a video. And generate_notebook returned early whenever the file existed, so a run created under an old template kept its old cells forever, with no way short of deleting the file.

Consequences we accept.

  • Values are JSON-escaped on substitution, because placeholders sit inside JSON string literals; an unescaped " in a path would corrupt the notebook. Tested directly rather than assumed.
  • A template whose substituted form is not valid JSON is not an error the user sees in the UI: expman logs the template path and writes the built-in default. Serving a working default beats surfacing the failure, given the alternative is a .ipynb Jupyter refuses to open.
  • content_hash is taken over the parsed JSON with the field itself removed, so it is insensitive to whitespace but sensitive to executed cells. Running cells therefore pins a notebook forever. That is the intended trade: outputs are the user's work.
  • The notebook is now written pretty-printed with an extra metadata block. The cells of the default are byte-for-byte what they were; the file around them is not.

Found while doing this, and fixed here: the multi-run generator spliced run names into JSON string literals unescaped, so a run directory named with a " or a \ produced an .ipynb Jupyter could not open. Pre-existing, silent, and caught only because the new code parses what it generates instead of writing it blind. The Python branch now escapes at each interpolation; the Rust branch escapes its whole snippet with the same helper, replacing a hand-rolled replace('\n', …).replace('"', …) that mishandled backslashes.


πŸ”—2026-08-19 β€” An asset is a known extension on the last segment, not any dot

The SPA fallback used to read "the path contains a ., and does not end in .html" as "this is a request for a bundled file". That is wrong the moment a route carries a dot, and expman's own users name experiments after their environment: every dm_control task id is domain.task, so /experiments/dmc-cartpole.swingup was classified as an asset and answered with a 404. The dashboard could not be reloaded or deep-linked on the exact experiments it was being used for.

is_asset_request now examines only the last path segment, and only accepts a suffix listed in ASSET_EXTENSIONS. A dot in a parent segment is part of a name, so /experiments/dmc-cheetah.run/runs/latest routes correctly too.

Two asymmetries are deliberate:

  • The list is wider than what the bundle embeds. The two failure directions are not equally bad. Answering a missing .js with index.html hands the browser HTML where it expected JavaScript, and the resulting syntax error names a file that was never served β€” a bug that hides its own cause. Answering a missing route with a 404 costs a reload. So the list errs toward "asset".
  • html is excluded. Serving the shell to an HTML request is precisely what SPA fallback is for, and it cannot mislead a parser.

The classifier only ever decides 404 vs shell: Assets::get() runs first, so anything genuinely in the bundle is served on its own merits regardless.

Accepted residual: an experiment named exactly like a file β€” foo.json, bar.css β€” would still 404 its detail page. Fixing that properly means the server knowing the set of experiment names, which makes a static asset check into a store query on every request. Not worth it until someone hits it.

πŸ”—2026-08-19 β€” Compaction belongs to a run going terminal, not to the engine

Compacting the .arrow segments into Parquet lived only in the engine's close path. But the engine's close path is exactly what a hard-killed run never reaches, and exp reap β€” the command whose entire job is to finish such runs β€” only rewrote the status. A reaped run was therefore terminal and permanently slow: every later read re-parsed the whole segment heap. Measured on one store, 14 hard-killed runs held 6589 orphaned segments, and reading one took 27s against 1.6s once compacted.

The triple is now storage::compact_run, called from both the engine and cmd_reap.

Reap compacts before it writes the status, not after. The ordering is the crash-safety argument: cmd_reap only selects RUNNING runs, so a reap interrupted mid-compaction leaves the run RUNNING and the next reap finishes the job. Marking first and compacting second would strand a run that was interrupted in between β€” terminal, uncompacted, and never selected again.

Compaction errors are logged per metric family and never propagated: readers union segments with the Parquet either way, so a family that will not compact leaves the run slow rather than lost, and recording the terminal status matters more than the tidying.


πŸ”—2026-08-19 β€” The Docs workflow pins Zola, and the Justfile owns the version

Decision. docs.yml no longer installs taiki-e/install-action@zola, which is a shorthand for "newest release". It reads zola_version out of the Justfile and installs exactly that (0.22.1), and just check-zola asserts the zola the dev shell puts on PATH is the same one.

Why. Zola 0.23 replaced the template engine, and the reticle theme is written against the old one β€” {% macro post_row(page, date_format="%b %d") %} is an unknown tag to it, and get_section(path=parts.1) no longer parses either. From 0.23.3's release the Docs job failed on every push: releases 1.2.0, 1.2.1 and 1.3.0 all deployed nothing. Nothing else noticed, because no other workflow needs zola and docs.yml gates nothing β€” the docs simply stopped updating. Local builds stayed green the whole time on nixpkgs' 0.22.1, which is what made the drift invisible rather than merely broken.

Why the version lives in the Justfile. The obvious alternative was to let CI take zola from the flake, so there is one toolchain rather than two. nixpkgs publishes no versioned zola attribute β€” unlike wasm-bindgen-cli_0_2_108 β€” so flake.nix can only ask for zola and has no literal to point CI at; making CI resolve it would mean installing Nix in a job that otherwise needs none. The Justfile holds the single literal instead, CI reads it with just --evaluate, and check-zola is what keeps flake.nix honest about it.

Consequences. A new zola release can no longer reach CI on its own. A nix flake update that moves the dev shell's zola still can move the two apart β€” that is precisely what check-zola fails on, in just ci, rather than in a docs deploy nobody watches. Raising zola_version now means fixing the theme submodule first; the pin buys time for that, it does not substitute for it.


πŸ”—Open questions

The four questions that stood here on 2026-07-27 β€” version single-sourcing, gating releases on tests, .maturinignore, and the Tailwind CDN β€” were all resolved on 2026-07-28; see the entries above.

  • The reticle theme has not been ported to Zola 0.23. The pin above holds the docs build at 0.22.1, so this is not urgent, but it is a standing debt in a different repo (the docs/themes/reticle submodule). At least two incompatibilities are known β€” {% macro %} and integer tuple indexing (parts.1) β€” and 0.23.3 reports them one at a time, so the real count is unknown until someone fixes them in order.
  • github-release.yml is still invoked twice for the same tag, from publish.yml and nix.yml, so the final asset set remains race-dependent. Untouched by the release gating work.
  • A release commit now runs the test suite twice, once via ci.yml and once as the publish gate. Deduplicating means making ci.yml skip release commits, which is easy to get subtly wrong; left alone deliberately.
  • TPU hbm_usage/duty_cycle_percent have not been seen with a live value. The format is verified and the numeric path is exercised by tensorcore_utilization on the same host, but the TPU was idle, so those two columns were only ever observed as N/A. If a live reading ever carries a unit suffix, parse_leading_number already strips it.
  • The multi-run notebook is not templatable. --notebook-template applies to per-run notebooks only. A multi-run notebook has no single run, so {{run_dir}}, {{run_name}} and {{project}} have nothing to bind to, and a useful shared template needs its own placeholder set β€” a {{runs}} list plus a rule for rendering it as loader cells. Left on the built-in default deliberately, and said so in the CLI reference rather than left to be discovered. The staleness contract does apply to it, with the built-in content as its "template".
  • Sweeps have no Bayesian/early-stopping search. Grid and random only; successive halving would be the next addition and needs a live-metrics read from the agent, which the append-only segments now make cheap.

Reference

CLI Reference

πŸ”—Reference β€” the exp CLI

Hand-written from src/cli/mod.rs, updated 2026-07-28.

Binary name is exp (Cargo.toml default-run), though clap's name is set to "expman" (cli/mod.rs:71) so --help output says expman. Requires the cli feature. Entry: src/main.rs:6-8.

Logging: init_tracing (cli/mod.rs:15-23) reads RUST_LOG via EnvFilter, defaults to info, compact format without targets.

Errors surface as Error: {e} + exit(1). The CLI uses anyhow, not ExpmanError.


πŸ”—exp serve [DIR]

Gated on the server feature. Starts the dashboard.

argdefault
DIR./experiments
--host127.0.0.1
--port, -p8000
--no-liveoff
--read-onlyoff
--notebook-template <PATH>none β€” see discovery order below
--jupyter-command <CMD>jupyter

--read-only refuses every non-GET/HEAD/OPTIONS request with 403 {"error": "read_only"}. It is enforced as middleware over the whole router (api/mod.rs, enforce_read_only) rather than per handler, so routes added later are covered by default β€” including the Jupyter and TensorBoard start/stop POSTs, which spawn processes on the host. Use it to share a store with someone who should not be able to change it.

live_mode and read_only are stored in AppState and reported truthfully by /api/config. The two Jupyter flags are stored on the JupyterManager and are not reported by /api/config β€” nothing in the frontend needs them.

πŸ”—--notebook-template β€” the Interactive tab's cells

Added 1.3.0. The notebook a run's Interactive tab opens comes from a project-supplied .ipynb, so the cells can do project-specific work β€” load this run's checkpoint, render a rollout the training host could not β€” instead of the built-in "read vectors.parquet and plot it".

Discovery order (jupyter_service.rs, NotebookTemplateConfig::resolve):

  1. --notebook-template <PATH>
  2. <DIR>/.expman/notebook.ipynb β€” the convention, so a store can carry its own notebook and the flag can stay unset
  3. the built-in default

A --notebook-template that does not exist is warned about and skipped, falling through to 2 and 3; a typo in the flag does not stop the server.

The template is itself a .ipynb β€” valid JSON β€” and expman substitutes these placeholders anywhere in the file, cells or metadata alike:

placeholdervalue
{{run_dir}}absolute path to the run directory (also the notebook's cwd)
{{run_name}}the run's directory name
{{experiment}}the experiment's directory name
{{store}}absolute path to DIR, the store root
{{project}}the run's experiment's project, or "" when unassigned

Paths are absolute because a relative one would only resolve against the server's cwd, which the notebook has no reason to share.

Values are JSON-escaped before substitution. Placeholders sit inside JSON string literals, so a run directory containing " or \ would otherwise close the literal and corrupt the notebook. This is tested, not assumed (test_placeholder_values_are_json_escaped).

If the substituted template does not parse as JSON, expman logs an error naming the template path and writes the built-in default instead β€” a broken .ipynb would break the tab the template exists to improve.

The multi-run notebook is deliberately not templatable. POST /experiments/{exp}/jupyter/start writes an interactive.ipynb into the experiment directory, where {{run_dir}}, {{run_name}} and {{project}} have no value to take; a useful shared template would need its own placeholder set (a {{runs}} list plus a rule for rendering it), which is a separate design rather than a wider version of this one. It always gets the built-in default, and always will until that design exists.

πŸ”—Notebook staleness β€” regenerated unless you edited it

Added 1.3.0. Before then, an existing interactive.ipynb was kept forever, so a run generated under an old template never picked up a fix.

Every notebook expman writes carries a namespaced metadata.expman block:

"metadata": {
  "expman": {
    "generated_by": "expman 1.3.0",
    "template": "/abs/path/.expman/notebook.ipynb",
    "template_hash": "sha256:…",
    "content_hash": "sha256:…"
  }
}

On each launch (POST …/jupyter/start, or the notebook POST):

state on diskwhat happens
absentwritten
matches content_hash, template_hash unchangedleft alone, silently
matches content_hash, template_hash differsrewritten, logged at info
does not match content_hash β€” you edited itleft alone, logged at warn
no metadata.expman β€” hand-made, or pre-1.3.0left alone, logged at warn

content_hash covers the parsed JSON with content_hash itself removed (a field cannot hash itself), so a whitespace reformat still counts as unedited. Executing cells does not β€” outputs and execution_count are your work, and expman will not overwrite them. Delete the file to opt back into regeneration.

The rule applies to the multi-run notebook too, whose "template" is the built-in content β€” which names the runs, so changing the selected run set refreshes an unedited notebook.

πŸ”—--jupyter-command β€” which interpreter the kernel is

Added 1.3.0. A command line, not a program name; split POSIX-shell-style (via shlex), so a quoted path with a space survives. No shell runs, so pipes, redirection and $VAR are not available. expman appends notebook --no-browser --port=… … after it:

exp serve ./experiments --jupyter-command 'uv run --extra nb jupyter'
# actually executed:  uv run --extra nb jupyter notebook --no-browser --port=8888 …

Why this rather than a kernelspec. The kernel a notebook gets is the interpreter Jupyter itself runs under. Launch Jupyter from inside the project's virtualenv and its built-in python3 kernel is that venv's interpreter, so import <your package> works with no ipykernel install step and no kernelspec name for expman and the project to keep in sync. expman therefore does not register a global kernel and does not write a named kernelspec into the generated notebook β€” either would mutate your ~/.local/share/jupyter and then have to stay correct forever.

Two failure modes, both with messages that name the configured value:

  • unparsable (unbalanced quote) or empty β†’ exp serve refuses to start, rather than failing at the first click on the Interactive tab
  • the program does not exist β†’ the start request fails with Failed to start Jupyter with the configured command `…`: No such file or directory, not a bare ENOENT

The trap. expman runs the command with the run directory as cwd, because that is where Jupyter must serve interactive.ipynb from. A command that discovers its environment from cwd β€” uv run walking up for a pyproject.toml β€” therefore only works when the store lives inside the project. When it does not, name the project explicitly instead of changing directory:

--jupyter-command 'uv run --project /path/to/project --extra nb jupyter'

πŸ”—exp list [DIR]

argdefault
DIR./experiments
--experiment, -enone
--project, -pnone
--group, -gnone
--tag, -tnone
--status, -snone
--runsoff

Two views. Experiment view (the default) is a table of Experiment | Project | Runs | Display Name, narrowed by --project. Run view is a flat cross-experiment table of Run | Experiment | Project | Status | Started | Duration | Tags; a None duration prints as running.

Run view is selected by --runs, by --experiment, or by any run-level filter (--tag, --status, --group) β€” asking a question about runs gives you runs. A Group column appears only when something is actually grouped, shown as group[rank].

--tag takes an expression: clauses are ANDed, alternatives within a clause are ORed.

exp list experiments --tag "arm:tiered AND (study:1 OR study:2)"
exp list experiments --tag "arm:tiered,study:1"   # , is AND, | is OR

Operators are matched case-insensitively but only as bare whitespace-delimited words, so a tag like brand:ORACLE is not torn in half.

πŸ”—exp project <SUBCOMMAND>

The projects layer without a server β€” a cluster node or tmux session has no dashboard, which is what made this layer unreachable from code before.

subcommandwhat
ls [DIR]Project | Experiments | Runs | Source | Display Name
new <NAME> [--display-name] [--description] [--tag ...]create a project
show <NAME>metadata plus its experiments
assign <EXPERIMENT> <PROJECT>set project: in experiment.yaml
unassign <EXPERIMENT>clear it
rm <NAME> [--force]delete the project, unassigning its experiments (runs are never deleted); dry run without --force
sync <MANIFEST> [--dir]generate projects from a YAML manifest

All take --dir (default ./experiments), except ls which takes DIR positionally.

πŸ”—exp project sync

One-way. The manifest is authoritative; each sync overwrites the project's metadata, README, and experiment membership, and marks it generated: true. The dashboard then refuses edits to it with 409 generated_project and hides the edit affordances β€” accepting a write it is about to destroy would be worse than refusing it.

projects:
  - name: study-1
    display_name: "Study 1 β€” Drift regret"
    description: "Does tiered allocation reduce drift regret?"
    tags: [thesis, study1]
    generated_from: "studies.yaml (thesis repo)"
    experiments: [e1-drift-regret-slope, x2-shift-sweep]
    frontpage:
      question: "Does a tiered arm dominate a flat arm under covariate shift?"
      scope: "3 domains, 5 seeds, 4 shift levels"
      domains: [vision, tabular, text]
      target_venue: "NeurIPS"
      target_date: "2026-05-15"
      status: "running"
      sections:
        - heading: "Notes"
          body: "Free-form markdown."

A bare top-level list of specs is accepted too. readme: supplies a raw body instead of rendering frontpage:. Membership is reconciled: experiments listed are assigned, and experiments previously in the project but absent from the manifest are unassigned. Naming an experiment that does not exist yet is reported, not an error β€” a project can be declared before its first run.

πŸ”—exp sweep <SUBCOMMAND>

Hyperparameter search. A sweep is a group of runs, one per trial β€” it needs no new storage concept and no server.

subcommandwhat
preview <CONFIG> [--limit]expand the config and print the trials; runs nothing
run <CONFIG> [--dir] [-j N] [--dry-run]execute trials locally, N at a time
slurm <CONFIG> [--dir] [-o FILE] [--partition] [--time] [--gpus] [--cpus] [--mem] [--log-dir] [--max-concurrent] [--sbatch ...]emit an sbatch array
status <NAME> [--dir] [--metric M] [--minimize]trial states and a ranked leaderboard
name: lr-sweep            # becomes the group every trial belongs to
experiment: e1-drift      # trials are logged under this experiment
project: study-1          # optional; created if it does not exist
method: grid              # or: random
trials: 40                # random only
seed: 0                   # random only; sweeps are reproducible from this
command: "python train.py --lr {lr} --bs {bs}"
params:
  lr:    {values: [0.1, 0.01, 0.001]}
  bs:    {values: [16, 32]}
  wd:    {min: 1.0e-5, max: 1.0e-2, log: true}   # random only
metric:
  name: val_loss
  goal: minimize

Params reach the trial two ways, deliberately: substituted into command (so an existing argparse script needs no edits) and exported as EXPMAN_PARAM_LR etc. (so a shell wrapper or sbatch script can read them without seeing the command string). expman.sweep_params() returns them as a typed dict.

The sweep also exports EXPMAN_GROUP, EXPMAN_RANK, EXPMAN_RUN_NAME, EXPMAN_EXPERIMENT, EXPMAN_PROJECT, EXPMAN_BASE_DIR and EXPMAN_TAGS, all of which the Python Experiment picks up automatically. A training script needs no sweep-specific code at all.

Grid needs values. min/max is a continuous domain and only works with method: random; grid says so rather than silently sampling. log: true samples the exponent, which is what you want for learning rates β€” a uniform sampler puts ~90% of its mass in the top decade.

Random search is reproducible: the same seed yields the same trials, using an internal SplitMix64 rather than the rand crate so a dependency bump cannot change results.

πŸ”—exp probes

Shows which system-metric probes exist on this machine and takes one live sample. --all lists every probe considered, including unavailable ones.

This is the answer to "why am I not seeing GPU metrics?" β€” during a run an absent probe is skipped silently, which is right for logging and useless for debugging.

hostproberesult
2Γ— RTX A6000nvidia-smi6 metrics per GPU: gpu.N.util_pct, mem_used_mb, mem_total_mb, mem_util_pct, temp_c, power_w
TPU v6e (8 chips)tpu-infotpu.N.tensorcore_utilization; HBM and duty cycle appear once a framework opens the TPU
any Linux/proccpu.util_pct, mem.total_gb, mem.used_gb, mem.util_pct, proc.rss_gb

tpu-info has no JSON output. It prints Rich pipe tables, one per --metric. expman parses those directly. hbm_usage and duty_cycle_percent read N/A until a framework has the TPU open β€” libtpu only publishes them then β€” and are skipped rather than recorded as zero.

Custom probes need no rebuild; add them to config with format: nvidia_csv, json, pipe_table or key_value.

πŸ”—exp reap [DIR]

argdefault
DIR./experiments
--older-than1h (90s, 30m, 2h, 3d; bare number = seconds)
--project, -pnone
--experiment, -enone
--forceoff (dry run)

Marks stale RUNNING runs as CRASHED. A hard kill leaves a run RUNNING forever, which silently inflates /stats.active_runs.

Staleness comes from heartbeat_at, which the engine refreshes every heartbeat_interval_secs (default 30s). Runs written before heartbeats existed have no heartbeat_at and fall back to started_at β€” the conservative direction, since such a run is only reaped once it is genuinely old.

finished_at is set to the last heartbeat, not to when you noticed; otherwise a run reaped a week late would report a week-long duration.

Reaping also compacts the run (storage::compact_run), folding its live .arrow segments into Parquet. A hard-killed run never reached the engine's close path, so without this it stayed terminal and slow β€” every later read re-parses the whole segment heap. Compaction runs before the status is written: this command only selects RUNNING runs, so an interrupted reap leaves the run RUNNING and the next one finishes the job, whereas marking first would strand it terminal and uncompacted forever. Compaction failures are logged, not fatal β€” the status still gets recorded.

πŸ”—exp inspect <RUN_DIR>

Prints run/experiment/status/started/duration, then config.yaml verbatim, then a Vector | Value table of the last Parquet row plus a total row count, then a Scalar | Value table from run.yaml.scalars, then every artifact as path (N bytes) (cli/mod.rs:245-318).

The help text example (cli/mod.rs:111) shows experiments/my_exp/runs/20240101_120000 β€” the runs/ segment is spurious and does not match the real layout. See storage-layout.

πŸ”—exp clean [EXPERIMENT]

argdefault
EXPERIMENToptional
--dir./experiments
--project, -pnone
--group, -gnone
--tag, -tnone
--keep, -k5
--forceoff

Keeps the --keep most recent runs per experiment. At least one of EXPERIMENT, --project, --group or --tag is required β€” with no scope at all it refuses rather than cleaning the whole store.

With --tag, only matching runs are candidates and only they count toward --keep, so --tag arm:tiered --keep 5 keeps five tiered runs per experiment and leaves the others untouched.

Dry-run by default β€” prints what it would delete and "Dry run. Use --force to actually delete.". With --force, remove_dir_alls each victim. Relies on list_runs being newest-first plus split_off(keep) (cli/mod.rs:320-360).

πŸ”—exp export <RUN_DIR>

argdefault
RUN_DIRβ€”
--format, -fcsv β€” one of csv, json, tensorboard
--output, -ostdout for csv/json; ./tb_logs for tensorboard

Bails if vectors.parquet is absent (cli/mod.rs:380-443).

  • json β€” serde_json::to_string_pretty of the row maps.

  • csv β€” RFC 4180. The header is the union of every row's keys (step and timestamp first, the rest alphabetical), and fields containing a comma, quote, CR or LF are quoted with inner quotes doubled. A missing metric and an explicit null both render as an empty cell, since any placeholder would be indistinguishable from data.

    Both of those were previously wrong, and silently: the header came from rows[0], so a metric first logged mid-run was dropped from the file entirely, and unescaped values let a string containing a comma split into an extra column. Regression tests: test_cli_export_csv_includes_metrics_first_logged_mid_run.

  • tensorboard β€” writes via tensorboard_rs::summary_writer::SummaryWriter. Only as_f64()-able columns are emitted, as add_scalar(k, v as f32, step); step and timestamp are skipped.

πŸ”—exp import <INPUT>

argdefault
INPUTβ€” (a TB log dir or a single event file)
--dir./experiments

If INPUT is a directory, takes the first entry whose filename contains tfevents. Experiment name is INPUT's file name (fallback imported_tb); run name is a UTC timestamp. Parses with tboard::SummaryReader, folding SimpleValue summaries into a BTreeMap<step, ...> so rows come out step-ordered, then writes via storage::append_vectors.

It also writes a run.yaml with status FINISHED, the tags imported and tensorboard, and the last row's values, plus an experiment.yaml if absent. Without those an imported run had no metadata at all and every reader fell back to minimal_run_metadata, so the import showed up in the dashboard as CRASHED.


πŸ”—Via Python

pip install expman-rs installs an exp console script (pyproject.toml:24-25) pointing at expman.cli:main β€” a 31-line shim that execs the binary bundled at <site-packages>/expman/bin/exp and forwards the exit code (wrappers/python/expman/cli.py).

Nix caveat: nix build .#python3Packages.expman-rs has no step that populates expman/bin/, so that console script will report "Bundled binary not found" and return 1. Nix users should get the CLI from .#expman.

HTTP API Reference

πŸ”—Reference β€” HTTP API

Hand-written from src/api/*.rs, 2026-07-27. Authoritative over src/api/README.md, which is stale (it documents a nonexistent /api/events endpoint and mentions utoipa).

Router: src/api/mod.rs:42-140. All routes below are prefixed with /api (mod.rs:153). Anything else falls through to the embedded frontend (mod.rs:155). CORS is fully permissive β€” Any origin/method/header (mod.rs:146-149).

Rows marked NEW are from the uncommitted TensorBoard work.

πŸ”—Projects

MethodPathHandlerResponse
GET/projectsprojects.rs[{id, display_name, description, tags, experiments_count, created_at, generated, generated_from, generated_at}]
POST/projectsprojects.rs:53ProjectMetadata
GET/projects/{project}projects.rs{id, display_name, description, tags, created_at, generated, generated_from, generated_at, readme, experiments: [...]}
PATCH/projects/{project}projects.rs:151updated ProjectMetadata. Body: {display_name?, description?, tags?}
DELETE/projects/{project}projects.rs:178204 No Content (unassigns experiments)
GET/projects/{project}/runsprojects.rscross-experiment runs table with facets β€” see below
GET/projects/{project}/readmeprojects.rs{content}
PUT/projects/{project}/readmeprojects.rs:133{content}. Body: {content}

πŸ”—Experiments

MethodPathHandlerResponse
GET/experimentsexperiments.rs:17[{id, display_name, description, tags, project, runs_count}] (ad-hoc json!)
GET/experiments/{exp}/metadataexperiments.rs:39ExperimentMetadata
PATCH/experiments/{exp}/metadataexperiments.rs:60merged ExperimentMetadata. Body: {display_name?, description?, tags?, project?}
GET/experiments/{exp}/statsstats.rs:16[{run, status, started_at, finished_at, duration_secs, last_metrics}]

πŸ”—Runs

MethodPathHandlerNotes
GET/experiments/{exp}/runsruns.rs:26query ?metrics=a,b,c filters columns. Backfills vectors from vectors.parquet when empty (runs.rs:56-68)
GET/experiments/{exp}/runs/{run}/metadataruns.rs:88RunMetadata with latest scalars merged into vectors
PATCH/experiments/{exp}/runs/{run}/metadataruns.rs:118Body: {name?, description?, tags?}
GET/experiments/{exp}/runs/{run}/configmetrics.rs:118config.yaml as JSON

πŸ”—Metrics and streams

MethodPathHandlerNotes
GET/experiments/{exp}/runs/{run}/metricsmetrics.rs:28query ?since_step=N. Returns Vec<HashMap<String, Value>>
GET (SSE)/run/{exp}/{run}/stream/vectorsmetrics.rs:41every 500 ms re-reads vectors.parquet past the last seen step, emits a JSON array of new rows. 15 s keep-alive.
GET (SSE)/experiments/{exp}/runs/{run}/log/streammetrics.rs:78query ?file= (default run.log). Tails by byte offset; resets on truncation (:99). 15 s keep-alive.

Both streams terminate via .take_until(shutdown.cancelled()).

Note the vectors-SSE route is the one route that breaks the naming convention β€” /run/{exp}/{run}/... rather than /experiments/... (mod.rs:56). It is also currently unused by the frontend.

πŸ”—Artifacts

MethodPathHandlerNotes
GET/experiments/{exp}/runs/{run}/artifactsartifacts.rs:18Vec<ArtifactInfo>
GET/experiments/{exp}/runs/{run}/artifacts/contentartifacts.rs:34query ?path=. Raw bytes with a guessed content-type; .parquet returns {type:"parquet", data:[first 100 rows]} (:69-73). Path traversal is guarded by canonicalize + starts_with (:47-58).

πŸ”—Global

MethodPathHandlerResponse
GET/statsstats.rs{total_experiments, total_projects, total_runs, active_runs, stale_runs, total_storage_bytes}
GET/configstats.rs{live_mode, read_only, version} β€” both read from AppState, so they reflect the flags exp serve was actually given

πŸ”—Jupyter

Per-run, plus a __multi__ variant scoped to the experiment.

MethodPathHandler
GET/jupyter/availablejupyter_handlers.rs available_jupyter β†’ {backend: "jupyter"|"python"|"none"}
POST/experiments/{exp}/runs/{run}/jupyter/startstart_jupyter β†’ {port}
POST/experiments/{exp}/runs/{run}/jupyter/stopstop_jupyter
GET/experiments/{exp}/runs/{run}/jupyter/statusstatus_jupyter β†’ {running, port}
GET/experiments/{exp}/runs/{run}/jupyter/notebookget_jupyter_notebook β†’ {exists, content}
POST/experiments/{exp}/runs/{run}/jupyter/notebookcreate_jupyter_notebook β†’ {created, content} or 409
POST/experiments/{exp}/jupyter/startstart_multi_jupyter, body {runs: [String]}
POST/experiments/{exp}/jupyter/stopstop_multi_jupyter
GET/experiments/{exp}/jupyter/statusstatus_multi_jupyter
GET/experiments/{exp}/jupyter/notebookget_multi_jupyter_notebook
POST/experiments/{exp}/jupyter/notebookcreate_multi_jupyter_notebook

Both start routes write the notebook first, and since 1.3.0 that write may replace an existing interactive.ipynb β€” when expman wrote it, you have not edited it, and the template has changed since. An edited notebook is never overwritten. Full rules, and the --notebook-template placeholder list: CLI reference.

GET /jupyter/available reports on the configured Jupyter command (exp serve --jupyter-command, default jupyter), not a hardcoded jupyter β€” so a project that reaches Jupyter through uv run is detected rather than falling back to the ipython/python copy-paste view.

409 from the notebook POSTs means "the file on disk stands" β€” whether because it is already current or because it was edited and left alone. The two are the same answer to the caller; the distinction is in the server log (warn for an edited file, info for a regeneration).

πŸ”—TensorBoard β€” NEW / uncommitted

MethodPathHandler
GET/tensorboard/availabletensorboard_handlers.rs:14 β†’ {available}
GET/experiments/{exp}/runs/{run}/tensorboard/has_logs:20 β†’ {has_logs}
POST/experiments/{exp}/runs/{run}/tensorboard/start:30 β†’ {port}
POST/experiments/{exp}/runs/{run}/tensorboard/stop:43
GET/experiments/{exp}/runs/{run}/tensorboard/status:54 β†’ {running, port}

No multi-run variant exists, unlike Jupyter β€” see STATUS.md.

πŸ”—Frontend fallback

GET /* β†’ frontend::serve_frontend (frontend.rs:57). Serves a rust-embed asset if the path matches, else falls back to index.html for anything that looks like an SPA route, else 404.

The route/asset split is is_asset_request (frontend.rs:45): only the last path segment is examined, and only a suffix in ASSET_EXTENSIONS (:36) counts as an asset. A dot earlier in the path is part of a name, not an extension β€” so /experiments/dmc-cartpole.swingup and /experiments/dmc-cheetah.run/runs/latest are routes. html is excluded from the list on purpose. See architecture for why the list is wider than what the bundle embeds.

πŸ”—Notes

  • No WebSocket endpoints exist, despite axum's ws feature being enabled.
  • No caching. Every request re-reads the filesystem and re-parses Parquet.
  • Routes the frontend never calls: /run/{exp}/{run}/stream/vectors, /experiments/{exp}/runs/{run}/config, /experiments/{exp}/stats, /config.
  • Test coverage: tests/api_test.rs covers 7 routes in-process via tower::ServiceExt::oneshot. The Jupyter and TensorBoard routes are untested.

πŸ”—GET /projects/{project}/runs

GET /projects/{project} returns an experiment list only, which is not a view you can work in: with one project per study, the comparison worth living in cuts across experiments. This returns the flat runs table plus what a filter UI needs.

Query parameters (all optional):

parammeaning
tagsexpression, e.g. arm:tiered AND (study:1 OR study:2)
statusRUNNING, FINISHED, FAILED, CRASHED (400 otherwise)
experimentnarrow to one experiment
groupnarrow to one DDP job or sweep cohort
{
  "project": "study-1",
  "total": 2,
  "runs": [{ "run": "tiered-s2", "experiment": "...", "project": "study-1",
             "status": "FINISHED", "started_at": "...", "heartbeat_at": null,
             "tags": ["arm:tiered"], "scalars": {}, "vectors": {},
             "path": "experiments/..." }],
  "facets": {
    "tags":        { "arm:tiered": 2, "seed:1": 1, "study:1": 2 },
    "status":      { "FINISHED": 2 },
    "experiments": { "e1-drift-regret-slope": 2 },
    "groups":      { "lr-sweep": 6, "job-77001": 4 }
  },
  "metrics": ["regret"]
}

This is core::dto::ProjectRuns, so the frontend deserializes the same type the handler serializes. It backs the dashboard's Compare tab.

Facets are counted over the returned runs, so they narrow as filters are applied β€” the behaviour a filter UI needs to avoid offering options that lead to empty results. metrics is the union of metric names across those runs, so a caller knows what is comparable before fetching any series.

πŸ”—Read-only mode

exp serve --read-only rejects every request that is not GET/HEAD/OPTIONS with 403 {"error": "read_only", "message": ...}. It is middleware over the whole router (enforce_read_only in api/mod.rs), not a per-handler check, so new routes are covered by default. /config reports read_only so the frontend can hide controls rather than offering writes that will fail.

πŸ”—Generated projects

A project created by exp project sync carries generated: true. Writes to it β€” PATCH /projects/{p} and PUT /projects/{p}/readme β€” are refused with 409:

{
  "error": "generated_project",
  "message": "Project 'study-1' is generated from studies.yaml (thesis repo) and is regenerated on each sync. Edit the source and re-run `exp project sync` instead.",
  "generated_from": "studies.yaml (thesis repo)"
}

Accepting the write would be worse than refusing it: the dashboard would report success and the edit would vanish at the next sync with no trace. DELETE is refused on the same grounds.

πŸ”—Downsampling

GET /experiments/{exp}/runs/{run}/metrics returns at most 2000 points.

parammeaning
max_pointschange the cap; 0 disables it
full=1return every row β€” the escape hatch for export and analysis
since_steponly rows past this step (used by the SSE stream)

The reduction is Largest-Triangle-Three-Buckets, not a stride. A stride drops the single-row loss spike or divergence, which is exactly what someone opens a chart to find; LTTB keeps whichever point in each bucket contributes the most visible area, so extremes survive. First and last rows are always kept, so endpoints are exact.

Without this, a million-step run serialises to hundreds of MB of JSON and hangs the tab before it draws anything.

Python API Reference

πŸ”—Reference β€” Python API

Hand-written from wrappers/python/expman/*.py and src/wrappers/python/mod.rs, updated 2026-07-28.

pip install expman-rs β†’ import expman. expman.__version__ comes from CARGO_PKG_VERSION (mod.rs:295), i.e. it tracks Cargo.toml, not pyproject.toml.

The write path, the projects layer, and the read API cross into Rust. Console redirection, tensorboard_dir, the module-level singleton API, the to_pandas convenience, cli.py, and all of tensorboard.py are pure Python.

πŸ”—class Experiment

Experiment(name, run_name=None, base_dir="experiments",
           flush_interval_rows=50, flush_interval_ms=500,
           redirect_console=True,
           project=None, tags=None, description=None,
           heartbeat_interval_secs=30,
           group=None, rank=None,
           system_metrics_interval_secs=15,
           capture_provenance=True, capture_diff=False)

project, tags and description are written at creation, offline β€” no server required, so this works on a compute node running a SLURM batch job. project lands in experiment.yaml; tags and description land in run.yaml. Before this, consumers had to hand-patch run.yaml after close().

heartbeat_interval_secs controls how often the engine refreshes run.yaml.heartbeat_at, which is what lets exp reap distinguish a hard-killed run from a legitimately long one. Pass 0 to disable.

experiment.yaml is only created when absent, but an explicit project= on a later run still takes effect: the engine updates that one field rather than ignoring it.

group and rank are auto-detected and rarely passed by hand. rank comes from EXPMAN_RANK / RANK / SLURM_PROCID / OMPI_COMM_WORLD_RANK / LOCAL_RANK; group from EXPMAN_GROUP or the scheduler's job id. A DDP script therefore needs no expman-specific changes: each rank becomes its own run (named <group>-rank<N>, so ranks cannot collide), and the dashboard rolls the group into a single expandable row.

system_metrics_interval_secs samples GPU/CPU/RAM into system.parquet via subprocess probes β€” nvidia-smi, rocm-smi, tpu-info, plus /proc. Probes whose binary is absent are skipped silently, so a laptop logs CPU and memory and says nothing about GPUs. 0 disables sampling.

capture_provenance records git commit/branch/dirty, the command line, hostname and scheduler job ids into provenance.yaml. capture_diff additionally records the working-tree diff; it is off by default because a dirty tree routinely contains an edited .env or a pasted key, and this store may be shared.

redirect_console is Python-only (not a PyO3 parameter). When true, it opens <run_dir>/console.log line-buffered and swaps sys.stdout/sys.stderr for a Tee (__init__.py:34-48) that writes to both. Redirect failure is caught and printed as a warning. atexit.register(self.close) is registered at __init__.py:112.

memberbackingnotes
log_params(params: dict)PyO3merged into config.yaml. Types are preserved (bool/int/float stay distinct).
log_vector(values: dict, step: int|None = None)PyO3the hot path β€” a channel send. True and 1 both become Float(1.0).
log_scalar(key: str, value)PyO3replaces an existing value; lands in run.yaml.scalars.
save_artifact(path: str)PyO3copied under the run's artifacts/; relative paths preserved.
info(message) / warn(message)PyO3appended to run.log.
set_project(project: str|None)PyO3writes only project: in experiment.yaml; offline. None unassigns.
projectPyO3 getterreads it back from experiment.yaml.
set_tags(tags) / add_tags(tags)PyO3replace or union this run's tags. add_tags is idempotent per tag.
set_description(text)PyO3sets this run's description.
group / rankPyO3 gettersthis run's place in its group, or None.
is_primaryPythonTrue on rank 0 or a non-distributed run. Guard once-per-job work with it, not rank == 0, which is False when there is no rank.
log_image(tag, image, step=None)PyO3bytes, a path, a PIL Image, a matplotlib Figure, or an HWC/CHW numpy array. Raises on anything else.
log_figure / log_audio / log_videoPyO3as above; audio/video take encoded bytes.
log_histogram(tag, values=..., bins=64)PyO3bins here (numpy if present, pure Python otherwise), or pass edges=/counts= precomputed.
run_dir / run_namePyO3 gettersraise RuntimeError("Engine is closed") after close β€” unlike the write methods, which silently no-op.
tensorboard_dirpure Python (uncommitted)<run_dir>/tensorboard, makedirs(exist_ok=True). Intended for torch.profiler.tensorboard_trace_handler.
close(status="FINISHED")bothrestores stdout/stderr, closes the console file, then closes the engine. Idempotent.
__enter__ / __exit__Python__exit__ maps an exception to status="FAILED" and returns False (does not suppress).

Status strings are not validated. close() maps anything it does not recognize silently to FINISHED (mod.rs:194). A typo'd status is not an error.

with expman.Experiment("my_exp", base_dir="./experiments") as exp:
    exp.log_params({"lr": 3e-4, "batch_size": 32, "amp": True})
    for step in range(1000):
        exp.log_vector({"train/loss": loss, "train/acc": acc}, step=step)
    exp.log_scalar("best_acc", best)
    exp.save_artifact("model.pt")
# Everything the projects layer needs, from a batch job with no dashboard.
with expman.Experiment(
    "e1-drift-regret-slope",
    base_dir="./experiments",
    project="study-1",
    tags=["arm:tiered", "seed:1", "study:1"],
    description="tiered allocation, seed 1",
) as exp:
    ...

πŸ”—Module-level singleton

A global _current_exp (__init__.py:31). init(...) takes the same arguments as Experiment(...) and closes any existing global experiment first.

expman.init("my_exp")
expman.log_params({...}); expman.log_vector({...}, step=i)
expman.log_scalar(k, v); expman.save_artifact(path)
expman.tensorboard_dir()      # -> str | None   (uncommitted)
expman.info(msg); expman.warn(msg)
expman.close()

With no active experiment, log_*/save_artifact/tensorboard_dir print "Warning: No active experiment. Call expman.init() first."; info/warn silently no-op β€” an inconsistency, not a design.

πŸ”—Read API

Everything here reads an existing store, and returns plain dicts and lists so an analysis script works in a bare environment. to_pandas=True imports pandas lazily and only then β€” pandas stays an optional extra, never a runtime dependency of the wheel.

This is what previously did not exist, and its absence is why analysis scripts read Parquet and a side-car JSON manifest directly, leaving the manifest rather than expman as the durable record.

functionreturns
load_runs(base_dir="experiments", project=None, experiment=None, status=None, tags=None, to_pandas=False)run dicts, newest first
read_metrics(run_dir, to_pandas=False)one dict per logged row
load_config(run_dir)the run's config.yaml (logged params)
load_run(run_dir)the run's run.yaml metadata
load_projects(base_dir="experiments")every project, with its experiments
load_provenance(run_dir)git/command/hostname/scheduler captured at creation
read_system_metrics(run_dir, to_pandas=False)sampled hardware metrics
read_histograms(run_dir)logged distributions: tag, step, edges, counts, total
read_media(run_dir)logged images/audio/video: tag, step, file, bytes
sweep_params()this trial's hyperparameters, typed, or {} outside a sweep
sweep_name()the sweep this process belongs to, or None
assign_project(experiment, project, base_dir="experiments")None; assigns with no open run

Each load_runs() row carries run, experiment, project, status, started_at, finished_at, heartbeat_at, duration_secs, description, tags, scalars, vectors, and path. Feed that path straight to read_metrics() / load_config() β€” the two compose, so a caller never does path arithmetic.

load_runs() also takes group= to select one DDP job or sweep cohort.

tags accepts either a list (all must match) or an expression string with AND/OR and parentheses:

import expman

runs = expman.load_runs(tags="arm:tiered AND (study:1 OR study:2)")
for run in runs:
    metrics = expman.read_metrics(run["path"])
    params = expman.load_config(run["path"])
    print(run["run"], params["lr"], metrics[-1]["regret"])

df = expman.read_metrics(runs[0]["path"], to_pandas=True)   # needs pandas

assign_project() is Experiment.set_project() without an open run β€” for a sync script projecting an external manifest into the store.

read_metrics() reads the Parquet directly and is never downsampled β€” the cap applies to the HTTP endpoint the browser uses, not to analysis code.

πŸ”—Media and distributions

exp.log_image("samples/epoch", pil_image, step=epoch)   # or bytes, path, ndarray
exp.log_figure("confusion", fig, step=epoch)            # matplotlib
exp.log_histogram("weights/fc1", model.fc1.weight, step=epoch)

Images are written to <run>/media/ with a media.jsonl manifest, not into Parquet β€” a column of image blobs would make the metrics file useless for its actual purpose. Histograms are stored as one row per (tag, step) with JSON edges/counts, because bin counts vary and a column-per-bin schema would churn on every call.

πŸ”—Sweeps from a training script

import expman

params = expman.sweep_params()      # {"lr": 0.001, "bs": 32} β€” typed, or {}
with expman.Experiment("e1") as exp:  # group/project/run name come from the env
    exp.log_params(params)
    ...

πŸ”—class SummaryWriter β€” TensorBoard drop-in

wrappers/python/expman/tensorboard.py, pure Python. Signature-compatible with torch.utils.tensorboard.SummaryWriter; purge_step, max_queue, flush_secs, and filename_suffix are accepted and ignored.

log_dir mapping (tensorboard.py:81-86):

log_dirbecomes
"runs/exp1"Experiment("exp1", base_dir="runs")
"my_exp" (no separator)Experiment("my_exp", base_dir="experiments")
Noneruns/<Mon DD_HH-MM-SS>_<hostname><comment>

Implemented: add_scalar β†’ one log_vector; add_scalars β†’ prefixes keys with f"{main_tag}/{k}" into one log_vector; add_text β†’ info(...); add_hparams β†’ log_params + conditional log_vector; flush() is a documented no-op (expman auto-flushes); close(), __enter__/__exit__.

Also implemented, since 2026-07-29: add_histogram, add_image, add_images and add_figure store natively; add_audio/add_video store encoded bytes.

Warn-once, no longer silent. add_graph, add_embedding, add_pr_curve and add_mesh cannot be stored, and now emit a UserWarning the first time each is called. They were previously silent no-ops, so swapping torch.utils.tensorboard.SummaryWriter for this class threw away every image and histogram a user logged with nothing said at runtime β€” code that looked like it worked. For those four, point a real TensorBoard writer at exp.tensorboard_dir; the dashboard renders it in the TensorBoard tab. add_custom_scalars stays quiet deliberately: it is a layout hint carrying no data.

The compat layer warns rather than raises on input it cannot encode, unlike the native log_image, which raises. It wraps code written for TensorBoard, often mid-way through a multi-day run; killing that run over one unencodable image would be a worse failure than the one being reported.

The constructor eagerly calls log_params({}) and info("SummaryWriter initialized") (:97-98) so the run's files appear on disk immediately.

πŸ”—expman.cli:main

A 31-line shim (cli.py) exposed as the exp console script. Resolves <package_dir>/bin/exp (exp.exe on win32), best-effort chmod 0o755, then subprocess.call([bin_path] + sys.argv[1:]) and forwards the exit code. Missing binary β†’ stderr message and return 1.

πŸ”—Behaviors worth knowing

  • All write-path errors are swallowed in the PyO3 layer. Lock poisoning and a closed engine both produce Ok(()). Only the getters raise.
  • The GIL is never released. See architecture.
  • panic = "abort" means a Rust panic aborts the whole Python process rather than raising a Python exception.
  • abi3-py39 + requires-python = ">=3.9" means one wheel per platform covers 3.9+. Note ruff is configured with target-version = "py310" (pyproject.toml:36) β€” a mismatch that could let 3.10-only syntax through and break 3.9 users.

πŸ”—Tests

wrappers/python/tests/test_experiment.py (6 tests) and test_tensorboard.py (14). The important one is test_vectors_vs_scalar (test_experiment.py:90), which pins the scalars-vs-vectors split in both directions.

Gaps: no test for tensorboard_dir; no test exercises cli.py; test_default_log_dir (test_tensorboard.py:45) takes no tmp_path and may write a runs/ dir into the CWD β€” which is why wrappers/python/.gitignore contains exactly runs/.

Storage Layout Reference

πŸ”—Reference β€” data model and on-disk layout

Hand-written from src/core/models.rs and src/core/storage.rs, 2026-07-27.

πŸ”—Directory layout

<base_dir>/                              default "./experiments"
β”œβ”€β”€ .expman/
β”‚   └── notebook.ipynb                   optional Jupyter template (read-only to expman)
β”œβ”€β”€ .projects/                           Project storage directory
β”‚   └── <project_name>/                  Project directory
β”‚       β”œβ”€β”€ project.yaml                 ProjectMetadata
β”‚       └── README.md                    Markdown frontpage
└── <experiment_name>/                   ExperimentConfig::experiment_dir()
    β”œβ”€β”€ experiment.yaml                  ExperimentMetadata (created if absent)
    β”œβ”€β”€ interactive.ipynb                multi-run notebook, if the tab was opened
    └── <run_name>/                      ExperimentConfig::run_dir()
        β”œβ”€β”€ run.yaml                     RunMetadata
        β”œβ”€β”€ config.yaml                  merged hyperparameters
        β”œβ”€β”€ vectors.parquet              metric rows
        β”œβ”€β”€ run.log                      engine log lines
        β”œβ”€β”€ console.log                  stdout/stderr tee (Python only)
        β”œβ”€β”€ interactive.ipynb            generated notebook, if the tab was opened
        β”œβ”€β”€ artifacts/                   user files, recursive
        └── tensorboard/                 only if Experiment.tensorboard_dir used

.expman/notebook.ipynb is the conventional notebook template (1.3.0): a .ipynb the store carries so the Interactive tab runs project-specific cells without anyone passing exp serve --notebook-template. expman only ever reads it. .expman/ is a dot-directory, so list_experiments already ignores it.

<run_name> defaults to chrono::Local::now().format("%Y%m%d_%H%M%S") (models.rs:32).

Run discovery is directory-name based, not index-driven. list_runs (storage.rs:59) returns every subdirectory except literal artifacts and .ipynb_checkpoints, sorted descending β€” which is newest-first only because of the timestamp naming convention. list_experiments (:27) ignores all dot-directories (e.g., .projects, .ipynb_checkpoints) and sorts ascending.

πŸ”—Types

All derive Debug, Clone, Serialize, Deserialize. Note there is no Experiment, Run, Metric, or Artifact struct β€” those are directories on disk plus the metadata structs below.

πŸ”—ExperimentConfig (models.rs:10-25)

Passed to the engine; not itself persisted.

fieldtypedefault
nameStringβ€”
run_nameStringlocal timestamp
base_dirPathBufβ€”
flush_interval_rowsusize50
flush_interval_msu64500
languageString"rust" (PyO3 sets "python")
env_pathOption<String>PyO3 fills from sys.executable

Builder: new(name, base_dir), .with_run_name(run_name).

πŸ”—MetricValue (models.rs:58-63)

#[serde(untagged)] β€” serializes as the bare scalar.

enum MetricValue { Float(f64), Int(i64), Bool(bool), Text(String) }

From impls for f64, f32, i64, i32, usize, bool, String, &str. No From<u64>. From Python, Int/Bool are effectively unreachable β€” see architecture.

πŸ”—VectorRow (models.rs:119-123)

step: Option<u64>, timestamp: DateTime<Utc>, values: HashMap<String, MetricValue>.

πŸ”—RunStatus (models.rs:138-143)

#[serde(rename_all = "UPPERCASE")] β†’ RUNNING | FINISHED | FAILED | CRASHED.

πŸ”—Files in a run directory

filewhat
vectors.parquetcompacted metrics, written at close
vectors-NNNN.arrowlive append-only segments; folded into the Parquet at close
system.parquet / system-NNNN.arrowsampled hardware metrics
histograms.parquet / histograms-NNNN.arrowone row per (tag, step): JSON edges, counts, total
media/ + media.jsonllogged images/audio/video and their manifest
provenance.yamlgit commit/branch/dirty, command, hostname, scheduler ids
run.yaml, config.yaml, run.log, console.logas before
.run.lockadvisory lock file for run.yaml updates
interactive.ipynbthe Jupyter tab's notebook, generated on first launch

interactive.ipynb is the one file in a run directory expman writes for the user rather than about the run, so it is the one file the user may own. Since 1.3.0 each generated copy carries a metadata.expman block β€” generated_by, template, template_hash, content_hash β€” and that is what decides whether a later launch may refresh it. A notebook whose content no longer matches content_hash, or that has no such block at all, is never overwritten. See the CLI reference.

Metrics are append-only while a run is live. Each flush appends an Arrow IPC batch rather than rewriting the Parquet, so cost is proportional to the rows flushed rather than to the run's history. Rewriting made total write volume grow with the square of the step count; measured on 10k steps, the old path took 48s and the new one 0.4s.

A metric first logged mid-run rolls a new segment, because one IPC stream carries one schema. Readers union the segments with the Parquet and merge rows sharing a step, so read_run_vectors sees a live run's data β€” a reader that opened only the Parquet would see nothing until the run closed.

A segment truncated by a hard kill yields every batch completely written and stops. Compaction writes the Parquet before deleting segments, so an interruption leaves both and readers union them to the same result.

πŸ”—RunMetadata β†’ run.yaml

name, experiment, status, started_at, finished_at, duration_secs, description, tags, plus #[serde(default)] fields: heartbeat_at, scalars (latest scalar value per key), vectors (latest value per vector key β€” a summary, not the series), language, env_path.

heartbeat_at is refreshed by the engine every heartbeat_interval_secs (default 30s) for as long as the run is RUNNING. It exists so a hard-killed run β€” which stays RUNNING forever β€” is distinguishable from a legitimately long one. None on runs written before heartbeats existed; readers fall back to started_at, which is the conservative direction. See exp reap, storage::is_run_stale, and storage::looks_alive.

description and tags are now written at creation when supplied to Experiment(...), rather than requiring a hand-patch after close().

group and rank place a run in a cohort β€” the N ranks of a DDP job, or the trials of a sweep. Both are auto-detected from the launcher's environment.

All mutations go through storage::update_run_metadata, which takes an exclusive advisory lock on .run.lock. Under DDP every rank ticks its own metadata update; a bare load-mutate-save races and silently drops the loser's fields. save_yaml also writes atomically (temp file + rename), so a reader never sees a half-written run.yaml and misreports the run as CRASHED.

Default sets status: Crashed (models.rs:181). That is deliberate: a missing or unparseable run.yaml degrades to minimal_run_metadata (storage.rs:193-212), which infers name/experiment from the path and reports CRASHED.

πŸ”—ExperimentMetadata β†’ experiment.yaml

display_name: Option<String>, description: Option<String>, tags: Vec<String>, project: Option<String>.

project is the whole projects hierarchy: a run's project is resolved through its experiment, so no run data ever moves when a project is created or reassigned. It can be written offline β€” storage::set_experiment_project rewrites only that field β€” via Experiment(project=...), Experiment.set_project(), expman.assign_project(), exp project assign, or exp project sync.

πŸ”—ProjectMetadata β†’ project.yaml

display_name: Option<String>, description: Option<String>, tags: Vec<String>, created_at: Option<DateTime<Utc>>, plus the generated-projection marker: generated: bool, generated_from: Option<String>, generated_at: Option<DateTime<Utc>>.

A project with generated: true is a one-way projection of a source outside expman (see core/projects.rs). It is overwritten wholesale by the next exp project sync, so the HTTP API refuses writes to it with 409 and the dashboard hides its edit affordances. Its README.md carries a matching <!-- expman:generated ... --> marker on the first line.

πŸ”—ArtifactInfo (storage.rs:144-150)

Serialize only; lives in storage.rs, not models.rs. Fields: path (relative), name, size, ext (lowercased), is_default.

list_artifacts (:69-111) returns two classes: whitelisted run-root files (vectors.parquet, config.yaml, run.yaml, run.log, console.log) with is_default: true, plus a recursive walk of artifacts/ with is_default: false.

πŸ”—Parquet schema

Inferred per batch (rows_to_record_batch, storage.rs:448-535), not declared:

columntypenullable
stepInt64yes
timestampTimestamp(Microsecond, "UTC")no
each metric keyFloat64 or Utf8yes

Metric column order is first-seen across the batch. Type comes from the first non-null occurrence: Float/Int β†’ Float64 (ints widen); Text, Bool, or a leading null β†’ Utf8 (Bool/Float/Int stringified as fallback).

Compression is SNAPPY (:379-388).

Schema evolution across batches goes through merge_schemas (:404-417) β€” existing fields first, then new-only fields appended β€” and align_batch (:419-446), which back-fills missing columns with typed nulls.

Known defect (verified 2026-07-27). storage.rs:272 downcasts the existing step column to UInt64Array while :475/:486 write it as Int64. The downcast always returns None, the else at :286 keeps the existing batch unfiltered, and the cross-batch step-dedup at :270-291 is dead code. Re-logging the same step in two different flush batches yields duplicate rows. In-batch dedup (:234-252) works fine.

πŸ”—Read functions

functionlinebehavior
read_vectors(path):303all rows as Vec<HashMap<String, serde_json::Value>>; vec![] if the file is absent
read_vectors_since(path, since):312reads everything, then filters step > since in memory. Rows without a parseable step are kept. Used by the 500 ms SSE loop.
read_latest_scalar_metrics(path):334reads all rows, takes .last() in file order, not step order, drops step/timestamp, keeps only as_f64()-able values

record_batch_to_rows (:537-588) converts to JSON: nulls β†’ Value::Null; Float64 NaN/Inf β†’ Value::Null (keeps the JSON valid); timestamps β†’ RFC3339 strings; unhandled Arrow types β†’ Null.

All of these load the full Parquet file into memory β€” there is no predicate or row-group pushdown.

πŸ”—Error type

ExpmanError (src/core/error.rs:6-35), thiserror:

Io Β· Arrow (not wasm32) Β· Parquet (not wasm32) Β· Yaml Β· Json Β· ChannelClosed Β· RunNotFound(String) Β· ExperimentNotFound(String) Β· Other(String)

The two NotFound variants are declared but never constructed β€” the code returns empty defaults instead.

Module Map Reference

πŸ”—Reference β€” module map

Hand-written 2026-07-27. ~8,100 lines of Rust in src/.

Two orthogonal gating axes: cargo features (cli, server, python; all opt-in, default = []) and target arch (wasm32 vs not).

πŸ”—Crate roots

filegatewhat
src/lib.rsβ€”18 lines of module declarations; crate docs are include_str!("../README.md")
src/main.rsclithe exp binary: init_tracing() then run_cli()
src/app/main.rswasm32the expman-app binary: mount_to_body(App)
build.rsβ€”invokes trunk build --release when server is on. Read docs/how-to/build-and-run.md before touching.
// src/lib.rs
pub mod core;                                             // always
#[cfg(all(feature = "server", not(wasm32)))] pub mod api;
#[cfg(wasm32)]                               pub mod app;
#[cfg(feature = "cli")]                      pub mod cli;
#[cfg(feature = "python")]                   pub mod wrappers;

πŸ”—src/core/ β€” always compiled

filegatewhat
models.rsβ€”ExperimentConfig, MetricValue, VectorRow, RunStatus, RunMetadata, ExperimentMetadata, ProjectMetadata β€” the storage models
dto.rsβ€”the wire types: Experiment, Project, ProjectDetail, Run, Artifact, GlobalStats, ServerConfig, ReadmeContent, InteractiveBackend, … Compiled for native and wasm32 so the server and the frontend share one definition; src/app/models.rs only re-exports them
error.rsβ€”ExpmanError (Arrow/Parquet variants are non-wasm only)
engine.rsnot wasm32LoggingEngine, LogLevel, LogCommand, background_task
storage.rsnot wasm32Parquet read/write, YAML helpers, list_runs/list_experiments/list_artifacts, ArtifactInfo, the project helpers (set_experiment_project, list_project_experiments), and the shared run index (RunEntry, RunQuery, query_runs, parse_tag_expr, is_run_stale, looks_alive)
projects.rsnot wasm32one-way project sync from a YAML manifest: ProjectManifest, sync_project, README rendering, the generated marker
sweep.rsnot wasm32sweep expansion (grid/random, seeded SplitMix64), trial command/env rendering, sbatch emission
sysmetrics.rsnot wasm32subprocess probes for GPU/CPU/RAM: ProbeSpec, SystemSampler, the nvidia-CSV and generic-JSON parsers
provenance.rsnot wasm32git/command/hostname/scheduler capture written to provenance.yaml

The wasm boundary is here: models and error are the only core modules the frontend sees. It reuses the data types but gets no engine, no storage, and no Arrow/Parquet/tokio.

Re-exported at core:: root: LoggingEngine, LogLevel, ExpmanError, ExperimentConfig, MetricValue, RunMetadata, RunStatus, VectorRow. Not re-exported (reach via the module path): models::ExperimentMetadata, storage::ArtifactInfo, error::Result, all storage free functions.

core/mod.rs:21-23 also has a doc-only pub mod jupyter_integration {} that exists purely to inline src/app/README.md into rustdoc.

πŸ”—src/api/ β€” server feature, non-wasm

filewhat
mod.rsrouter, CORS, serve(), graceful shutdown
state.rsAppState (base_dir, jupyter, tensorboard, shutdown token), ServerConfig β€” flag-shaped, no file config; parses jupyter_command into a JupyterCommand when building the manager
projects.rs Β· experiments.rs Β· runs.rs Β· metrics.rs Β· artifacts.rs Β· stats.rshandlers β€” see http-api
frontend.rsrust-embed of dist/, SPA fallback
jupyter_service.rs1331 lines: JupyterCommand (the configured launch command line), NotebookTemplateConfig (flag β†’ convention β†’ built-in) + NotebookContext (placeholder values), .ipynb rendering with JSON-escaped substitution, the metadata.expman staleness stamp, backend detection, port scan 8888–9999, spawn/status/stop. 23 unit tests from :890.
jupyter_handlers.rsper-run and __multi__ handlers; builds the NotebookContext, reading {{project}} from the experiment's experiment.yaml
tensorboard_service.rsuntracked. 175 lines, near-verbatim clone of the Jupyter manager. Port scan 6006–7999. No tests.
tensorboard_handlers.rsuntracked. 5 handlers.

πŸ”—src/app/ β€” wasm32 only (Leptos 0.8 CSR)

filewhat
mod.rsApp, sidebar shell, routes
main.rswasm entry
models.rsthin re-export of core::dto (since 2026-07-28). Previously hand-mirrored JSON types, which drifted; add fields to core::dto instead
fetch.rs411 lines of gloo_net calls returning Result<T, String>
utils.rsSidebarContext, CHART_COLORS, Liang–Barsky clipping, canvasβ†’PNG download
pages/dashboard.rs/ β€” global stats + recent experiments + recent projects
pages/projects.rs/projects β€” project grid and creation modal
components/hparams.rsthe Compare tab: params Γ— final-metrics table plus an SVG scatter, fed by /projects/{p}/runs
pages/project_detail.rs/projects/:id β€” overview with markdown README and experiments list
pages/experiments.rs/experiments
pages/experiment_detail.rs/experiments/:id β€” tabs + run-selection sidebar + metadata editing
pages/settings.rs/settings β€” one debug_enabled toggle in localStorage
pages/not_found.rsfallback
components/charts.rs~1013 lines: MetricsView, LineChart, ScalarChart. Hand-rolled pan/zoom over plotters-canvas.
components/runs_table.rstable + CSV/LaTeX/Typst export via blob download
components/artifacts.rsartifact browser, tabular preview
components/console.rslive log via raw web_sys::EventSource
components/interactive.rsJupyter tab β€” iframes http://localhost:{port}
components/tensorboard.rsuntracked. TensorBoard tab, single-run only.
components/zoom.rsshared zoom controls

Frontend styling is Tailwind from a CDN hardcoded in src/app/index.html:8. stylist is a declared dependency that is used nowhere.

πŸ”—src/cli/ β€” cli feature

Single mod.rs: clap definitions plus every subcommand implementation. See cli.

πŸ”—src/wrappers/ β€” python feature

mod.rs re-gates pub mod python; python/mod.rs (298 lines) is the #[pymodule]. See python-api.

πŸ”—Outside src/

pathwhat
wrappers/python/the PyPI package: expman/{__init__,cli,tensorboard}.py, pyproject.toml, tests/
tests/integration_test.rs (12 core tests), api_test.rs (7 HTTP tests), cli_test.rs (10 binary tests)
examples/rust/logging.rs, test_rust.rs
examples/python/basic_training.py, singleton_usage.py, tensorboard_migration.py
Justfileevery build/test/lint/release command
flake.nixdevShell + packages.expman + packages.python3Packages.expman-rs
.github/workflows/11 workflows β€” see release
dist/trunk output, gitignored but shipped in the crate via include

πŸ”—Stale in-tree docs

src/api/README.md, src/app/README.md, and src/app/components/README.md are rendered into rustdoc via #![doc = include_str!]. src/api/README.md is stale β€” it documents a nonexistent /api/events endpoint and mentions utoipa. Prefer docs/reference/.

How-To Guides

Development Setup

πŸ”—How-to β€” set up a dev environment

Verified 2026-07-27.

πŸ”—Nix (the supported path)

nix develop

Gives you (flake.nix:57-80): the fenix stable Rust toolchain with the wasm32-unknown-unknown target, pkg-config, openssl, just, trunk, wasm-bindgen-cli, uv, maturin, protobuf, and cargo-nextest. Sets RUST_LOG=debug, RUST_BACKTRACE=1, and PYO3_PYTHON to nixpkgs python312.

A Cachix cache is declared in the flake's nixConfig, so it applies to you automatically. To use it outside the flake:

cachix use lokeshmohanty

πŸ”—Without Nix

You need: a Rust toolchain with the wasm32-unknown-unknown target, just, trunk, wasm-bindgen-cli, uv, maturin, protobuf (the tboard crate needs protoc), and cargo-nextest.

rustup target add wasm32-unknown-unknown
cargo install trunk wasm-bindgen-cli cargo-nextest
# just, uv, maturin, protobuf via your package manager

πŸ”—One-time, per clone β€” required

The exp binary bundled into the Python wheel lives at wrappers/python/expman/bin/. It must not be committed (it is 13 MB and platform-specific β€” on this machine it is dynamically linked against a /nix/store glibc) and it must not be added to .gitignore (maturin honours .gitignore and would drop it from the wheel).

The only correct place to exclude it is your local git exclude file:

echo "wrappers/python/expman/bin/" >> .git/info/exclude

This is documented in CONTRIBUTING.md:32-44. Until you run it, git add -A will stage a 13 MB binary.

πŸ”—Python dev environment

just dev-py

Builds the CLI binary, copies it into the package, creates a uv venv --seed --python 3.12 if absent, runs maturin develop, and installs the package editable with its dev extra.

πŸ”—Optional β€” pre-commit hooks

.pre-commit-config.yaml exists but nothing installs it, and CONTRIBUTING.md does not mention it. If you want it:

uv run --extra dev pre-commit install

It runs whitespace/EOF/yaml/toml checks, cargo fmt --check, cargo clippy --all-targets --all-features -D warnings, and ruff (check --fix plus format).

Two caveats. (1) The clippy hook uses --all-features, which enables server, which makes build.rs invoke trunk on every Rust commit unless dist/index.html already exists β€” so hooks can be slow or can fail on a clean checkout. Export EXPMAN_SKIP_FRONTEND_BUILD=1 to avoid it. (2) ruff-format runs here but not in CI, so formatting drift from contributors without hooks installed is never caught.

πŸ”—Next

  • build-and-run β€” the build graph and how to run things
  • test-and-lint β€” what each suite covers

Building and Running

πŸ”—How-to β€” build and run

Verified 2026-07-27. Prefer just recipes over raw cargo β€” several of them exist specifically to route around build.rs.

πŸ”—The one thing to understand first: build.rs

When the server feature is enabled and the target is not wasm32, build.rs shells out to trunk build --release to produce dist/. On failure it falls back to an existing dist/index.html, writes placeholder assets during CARGO_DOC, and otherwise hard-exits(1) (build.rs:44).

So cargo build --all-features on a clean checkout without trunk on PATH fails at build-script time with an error that does not obviously say "install trunk".

Two escape hatches:

export EXPMAN_SKIP_FRONTEND_BUILD=1   # skip trunk; asserts dist/index.html exists
just prep-dist                         # mkdir -p dist && touch dist/index.html

CI uses both: every Rust job downloads a prebuilt frontend-dist artifact into dist/ and sets EXPMAN_SKIP_FRONTEND_BUILD=1.

build.rs reruns on changes to src/app, Trunk.toml, and Cargo.toml, and builds with CARGO_TARGET_DIR=target/wasm_build with MAKEFLAGS stripped (a recursive-cargo guard).

πŸ”—Common commands

commandwhat
just buildfrontend + python extension + cargo build --all-features
just build-releasesame, release profile
just build-frontendtrunk build --release β†’ dist/
just build-pybuild the CLI, copy it into the package, maturin develop --release
just dev-pysame but debug β€” the normal Python dev loop
just build-docscargo doc --no-deps --all-features + a redirect index.html
just checkfmt-check + lint-rust + lint-py + cargo check --all-features
just cleancargo clean and remove built .sos

πŸ”—Running

just serve [DIR]           # dashboard on 127.0.0.1:8000, DIR defaults to ./experiments
just list [DIR]            # list experiments
just run <ARGS>            # cargo run --features cli,server -- <ARGS>
just example-rust          # examples/rust/logging.rs
just example-py            # examples/python/basic_training.py (runs dev-py first)
just bench                 # test_log_vector_is_fast in release, with output
just stats                 # tokei over src/ and wrappers/python/

The dashboard needs both features: cargo run --features cli,server -- serve. serve is gated on server; without it the subcommand does not exist.

πŸ”—Feature combinations that matter

you wantfeatures
CLI only, no dashboard--features cli
CLI + dashboard--features cli,server ← triggers the trunk build
Python wheel--features python ← never triggers trunk
everything (tests, clippy)--all-features ← triggers the trunk build

Wheel builds set features = ["python"] only (pyproject.toml:31), which is why maturin never needs trunk.

πŸ”—Frontend-only iteration

Trunk.toml targets src/app/index.html with dist = "dist". trunk serve will hot-reload the WASM app, but the API calls need a backend β€” run just serve in another shell and point the frontend at it (CORS is fully permissive, so cross-origin from the trunk dev server works).

Note the WASM bundle is built with data-wasm-opt="0" β€” the current expman-app_bg.wasm is ~2.6 MB.

πŸ”—Styling

Tailwind is built, not fetched. src/app/index.html declares data-trunk rel="tailwind-css" href="../../assets/tailwind.css"; trunk runs the standalone Tailwind CLI, pinned in Trunk.toml:

[tools]
tailwindcss = "3.4.17"

Trunk downloads that version on a normal build. The Nix build runs with TRUNK_OFFLINE=true, so it takes pkgs.tailwindcss_3 from nativeBuildInputs instead β€” the two must stay the same version. They currently emit byte-identical CSS; if you bump one, bump the other.

Config is tailwind.config.js. Two things to know:

  • Classes are scanned out of the Rust source (content: ["./src/**/*.rs"]). A class assembled at runtime β€” format!("text-{}", colour) β€” will not be generated and the element renders unstyled. Build such classes from a match that returns whole literals.
  • @tailwindcss/typography needs no npm. The standalone CLI bundles it, so the prose classes used for project READMEs work. They did not work under the old cdn.tailwindcss.com script, which ships no plugins β€” that markdown had been silently unstyled.

πŸ”—Fonts

Three type roles, self-hosted:

rolefacecarries
displaySpace Groteskheadings, the wordmark
bodyNunitoprose, descriptions, list items
monoCascadia Coderun IDs, metrics, counts, timestamps, tags, uppercase labels, code

The woff2 files are vendored into assets/fonts/ (latin + latin-ext only, ~273 KB) with @font-face rules in assets/fonts.css. index.html copies them via data-trunk rel="copy-dir" and links the CSS; api/frontend.rs embeds *.woff2 into the binary.

Two ways this breaks silently. If frontend.rs loses its #[include = "*.woff2"], the faces 404 and the page falls back to system fonts β€” which looks like a CSS bug. And code/pre are styled by Tailwind preflight from its own variable, not from the font config, so the mono role is set directly in the <style> block; drop that rule and every code block silently reverts.

To regenerate after a Fontsource release, unpack the three @fontsource-variable/* tarballs and copy the latin subsets, taking the unicode-range values from each package's own unicode.json rather than transcribing them:

for pkg in space-grotesk nunito cascadia-code; do
  curl -sL "https://registry.npmjs.org/@fontsource-variable/$pkg/-/$pkg-<VERSION>.tgz" \
    | tar xz -C "$pkg" --strip-components=1
done
# then copy files/*-latin{,-ext}-wght-{normal,italic}.woff2 into assets/fonts/
# and rebuild assets/fonts.css from each package's unicode.json

Verify from a loaded page, not from the stylesheet:

getComputedStyle(document.querySelector("h1")).fontFamily    // Space Grotesk Variable
getComputedStyle(document.querySelector("code")).fontFamily  // Cascadia Code Variable
[...document.fonts].filter(f => f.status === "loaded").map(f => f.family)
// Should be empty β€” the dashboard makes no third-party requests:
performance.getEntriesByType("resource").map(r => r.name).filter(n => !n.startsWith(location.origin))

πŸ”—New asset? git add it before nix build

A flake's src = ./. sees only git-tracked files. An untracked assets/whatever.css builds fine with trunk and then fails under nix build .#expman with a confusing "No such file or directory" for a path that plainly exists. Stage new assets before testing the Nix build.

πŸ”—Nix builds

nix build .#expman                          # the exp CLI (features cli,server)
nix build .#python3Packages.expman-rs       # the Python package
nix run github:lokeshmohanty/expman-rs      # run the CLI without cloning

packages.expman builds the frontend in preBuild with TRUNK_OFFLINE=true (forcing the Nix-provided wasm-bindgen-cli) and doCheck = false β€” tests are CI's job.

Caveat: packages.python3Packages.expman-rs has no step that populates expman/bin/exp, so its exp console script reports "Bundled binary not found". Get the CLI from .#expman instead.

πŸ”—Troubleshooting

symptomcause
build script exits 1 with a trunk errorserver feature on, no trunk. Set EXPMAN_SKIP_FRONTEND_BUILD=1 or just prep-dist.
dashboard loads unstyledTailwind is loaded from a CDN (src/app/index.html:8). You are offline.
Jupyter/TensorBoard tab shows a blank iframethose services are not proxied β€” the browser connects directly to localhost:{port}, so this only works when the browser and the server are on the same machine. Unaffected by --jupyter-command: that changes which interpreter is launched, not how the browser reaches it.
ImportError from import expmanthe extension is not built. just dev-py.
Interactive tab cannot import your project's packagethe kernel is the interpreter Jupyter runs under, which is whichever environment the server was started in. Launch Jupyter inside the project instead: exp serve --jupyter-command 'uv run --extra nb jupyter'.
edits to .expman/notebook.ipynb do not reach a run's Interactive tabthat run's interactive.ipynb has been edited, so expman will not overwrite it. The server logs a warn saying so; delete the file to opt back into regeneration.
exp serve exits with "is not a parsable command line"an unbalanced quote in --jupyter-command. It is validated at startup rather than at the first click on the tab.
clippy passes locally, fails in CIjust lint-rust includes the wasm target via lint-frontend; the pre-commit hook does not.

Testing and Linting

πŸ”—How-to β€” test and lint

Verified 2026-07-27.

πŸ”—Commands

commandwhat
just testtest-py then cargo nextest run --all-features
just test-releasecargo nextest run --all-features --no-capture β€” what CI runs
just test-pycd wrappers/python && uv run --extra dev pytest tests
just test-watchcargo watch -x 'nextest run --workspace'
just lintlint-rust + lint-py
just lint-rustlint-frontend first, then cargo clippy --all-features --all-targets -- -D warnings
just lint-frontendclippy against --target wasm32-unknown-unknown
just fmt / just fmt-checkcargo fmt --all
just cifmt-check lint test lint-py test-py β€” the full local gate

All Rust test commands use --all-features, which enables server and so triggers the build.rs trunk build. Set EXPMAN_SKIP_FRONTEND_BUILD=1 if you already have a dist/. See build-and-run.

cli_test.rs uses assert_cmd::cargo::cargo_bin_cmd!("exp"), which needs the cli feature β€” another reason the suite is always run with --all-features.

πŸ”—What each suite covers

πŸ”—tests/integration_test.rs β€” 12 tests, the core engine

Run dir creation Β· vectors β†’ Parquet Β· params β†’ YAML Β· test_log_vector_is_fast (the throughput benchmark, also just bench) Β· status persisted on close Β· artifact save (relative and absolute paths) Β· Parquet schema merge Β· latest-scalar readback Β· corrupt YAML metadata Β· concurrent logging from multiple threads Β· same-step replacement.

πŸ”—tests/api_test.rs β€” 7 tests, the HTTP layer

In-process via tower::ServiceExt::oneshot against build_router + AppState β€” no network. A TempDir fixture builds one experiment with one run.

Covers: list experiments, get/update experiment metadata, list runs, get run metadata, server config, get metrics.

Not covered: every Jupyter route, every TensorBoard route, both SSE streams, artifact content.

πŸ”—tests/cli_test.rs β€” 10 tests, the binary

list (with and without --experiment) Β· inspect Β· clean --keep 5 dry-run (asserts nothing is deleted without --force) Β· export json/csv, with and without data Β· import error paths Β· export --format tensorboard with no data.

πŸ”—wrappers/python/tests/ β€” 20 tests

test_experiment.py (6): basic file creation, singleton init, artifact round-trip, complex types in config.yaml, crash β†’ status: FAILED, and test_vectors_vs_scalar β€” the important one, pinning that scalars land in run.yaml and vectors in vectors.parquet, with cross-assertions both ways.

test_tensorboard.py (14): directory structure, context manager, default log_dir generation, scalars with and without step, add_scalars prefixing, add_text, add_hparams (including empty and None metrics), a parametrized sweep asserting all 11 stub methods do not raise, flush() no-op, sequential writers not interfering, and both log_dir mapping branches.

Not covered: tensorboard_dir; cli.py (the exp console script is never exercised from Python).

test_default_log_dir (test_tensorboard.py:45) takes no tmp_path and can write a runs/ directory into the CWD β€” which is why wrappers/python/.gitignore contains exactly runs/.

πŸ”—What CI runs

ci.yml (push + PR on main, ignoring **.md, examples/**, docs/**, .gitignore, LICENSE) calls build-assets, then rust.yml and python.yml in parallel.

  • rust.yml β€” just fmt-check + just lint-rust, then just test-release. Both jobs download frontend-dist and set EXPMAN_SKIP_FRONTEND_BUILD=1.
  • python.yml β€” just lint-py (ruff over wrappers/python and examples/), then downloads the CLI artifact, just bundle-cli-bin, uv sync --extra dev, builds and installs a wheel, and runs pytest.

Two gaps worth knowing:

  • ruff format never runs in CI β€” only ruff check. Formatting drift is caught only by pre-commit hooks, which nothing installs automatically.
  • Python tests only ever run on Linux. python.yml:29 computes the CLI artifact name from runner.os with a nested ternary, implying an intended OS matrix that does not exist; the job is always ubuntu-latest.

And the big one: a release does not wait for tests. See release.

Adding an API Endpoint

πŸ”—How-to β€” add an API endpoint end to end

Written 2026-07-27 by tracing how the TensorBoard integration was added.

Adding a route touches five or six files. The TensorBoard work in the current working tree is the best worked example β€” follow it file by file.

πŸ”—1. Handler β€” src/api/<area>.rs

Handlers take State<AppState> and Path/Query extractors and return Json<serde_json::Value> (most handlers build responses with the json! macro rather than typed structs β€” follow the local style).

pub async fn status_tensorboard(
    State(state): State<AppState>,
    Path((exp, run)): Path<(String, String)>,
) -> Json<serde_json::Value> { ... }

Resolve paths from state.base_dir β€” never trust a client path. For anything that reads a user-supplied path, copy the canonicalize + starts_with guard in src/api/artifacts.rs:47-58.

πŸ”—2. Register the route β€” src/api/mod.rs

Add pub mod <name>; and a .route(...) line in the router (mod.rs:42-140). Keep the /experiments/{exp}/runs/{run}/... shape β€” only one existing route deviates and it is regretted.

πŸ”—3. State, if the endpoint owns something β€” src/api/state.rs

AppState is Clone and cloned per request. Anything shared goes behind Arc<Mutex<...>> in a Clone newtype, following JupyterManager (jupyter_service.rs:262-265).

Use std::sync::Mutex, and never hold the guard across an .await. Every existing critical section is a short map operation. If you need to hold a lock across await points, that is a design change, not a local one.

If the endpoint spawns a process, register cleanup in serve()'s shutdown path (mod.rs:186-187) and honour state.shutdown_token.

πŸ”—4. Response type β€” src/core/dto.rs

Define the response shape here, once. core/dto.rs is compiled for native and wasm32, so the handler serializes exactly the type the frontend deserializes, and the compiler ties them together.

Do not return serde_json::json!({...}) from a handler and add a matching struct in src/app/models.rs. That is how these drifted for a long time: the two were hand-mirrored with nothing checking them, which silently shipped a status colour keyed on a "COMPLETED" value the API never emitted.

src/app/models.rs is a re-export list β€” add your type to the pub use there and nothing else.

Wire types stay distinct from the storage models in core/models.rs: a DTO may expose less than RunMetadata, but it does so through a constructor (dto::Run::new), so changing a storage field is a build error rather than a quietly changed endpoint.

πŸ”—5. Frontend fetch β€” src/app/fetch.rs

Plain gloo_net::http::Request, returning Result<T, String>. The house style is resp.text() then serde_json::from_str rather than resp.json().

Check the HTTP status. Several existing functions (stop_jupyter, stop_tensorboard) ignore it β€” do not copy that.

πŸ”—6. Component β€” src/app/components/

Wrap the fetch in LocalResource::new(...) and render inside <Suspense>. Declare it in components/mod.rs and mount it where it belongs (a tab in pages/experiment_detail.rs:375,405, for example).

If you open an EventSource or spawn anything, close it in on_cleanup β€” components/console.rs:105 is the correct pattern. components/tensorboard.rs omits this and leaks processes on navigation.

πŸ”—7. Test β€” tests/api_test.rs

In-process via tower::ServiceExt::oneshot against build_router + AppState, with a TempDir fixture (api_test.rs:12-49). No network, fast. Add a case per route; the existing 7 tests are the template.

πŸ”—Checklist

  • handler with path-traversal guard where relevant
  • route registered in mod.rs
  • AppState field + shutdown cleanup if it owns a resource
  • frontend model matching the wire format
  • fetch function that checks the status
  • component with on_cleanup for anything long-lived
  • test in tests/api_test.rs
  • docs/reference/http-api.md updated
  • just ci green

πŸ”—Things to know before you start

  • There is no caching. Every request re-reads the filesystem and re-parses Parquet. If your endpoint is hot, that is the constraint to design against.
  • Realtime is SSE only. Both existing streams are 500 ms poll loops with 15 s keep-alives, terminated by .take_until(shutdown.cancelled()) (metrics.rs:41, :78). Axum's ws feature is enabled but unused.
  • CORS is fully permissive (mod.rs:146-149). Fine for a localhost tool; do not add anything that assumes an origin check.
  • Spawned services are not proxied. Jupyter and TensorBoard are reached directly at http://localhost:{port}, which is why the dashboard only works from the machine running the server. If you are adding a third such service, consider building the proxy route instead β€” see decisions.

Cutting a Release

πŸ”—How-to β€” cut a release

Verified 2026-07-27 against the 11 workflows in .github/workflows/.

πŸ”—The procedure

just bump patch      # or minor, or major
git push

That is the whole thing. The commit message is the release trigger.

just bump:

  1. reads the current version from Cargo.toml
  2. rewrites it there and nowhere else β€” pyproject.toml takes it via maturin's dynamic = ["version"], and flake.nix reads it with builtins.fromTOML
  3. cargo update -p expman to refresh Cargo.lock
  4. runs just check-versions, which fails if a literal has been reintroduced
  5. commits Cargo.toml + Cargo.lock as release: bump version to X.Y.Z

It does not tag and does not push. The tag v<version> is created later by the GitHub Release action.

πŸ”—What fires on push

check-release.yml:26 regex-matches github.event.head_commit.message against:

^release: bump version to (\d+\.\d+\.\d+)$

Because a bump commit touches Cargo.toml and Cargo.lock, four workflows fire simultaneously: publish.yml (no path filter), nix.yml (matches the changed paths), docs.yml (no path filter), and ci.yml.

publish.yml orchestrates: check-release β†’ build-assets β†’ rust + python β†’ publish-cargo + publish-pypi in parallel β†’ github-release. The rust and python jobs are the release gate: nothing publishes unless both pass.

targetworkflowsecret / environment
crates.iopublish-cargo.ymlenv CARGO, CARGO_REGISTRY_TOKEN
PyPIpublish-pypi.yml (3-OS matrix β†’ uv publish)env PYPI, PYPI_API_TOKEN
GitHub Releasesgithub-release.ymlβ€”
gh-pages (rustdoc)docs.yml, on every push to mainβ€”
Cachixnix.ymllokeshmohanty cache

πŸ”—The traps

πŸ”—1. The regex is exact and failure is silent

Anchored, single spaces, no v prefix, no trailing period, exactly three numeric components. A squash-merge that rewrites the subject, a merge commit, or any suffix yields should_release=false β€” the push succeeds, nothing publishes, and no job fails. There is no negative signal. If a release did not happen, check the commit subject first.

Related: head_commit.message is only the last commit of the push. Pushing several commits where the bump is not last releases nothing.

πŸ”—2. Releases wait for tests β€” but not the ones in ci.yml

Fixed 2026-07-28. publish.yml calls rust.yml and python.yml itself, and both publish jobs need them, so a failing test blocks publication.

The subtlety worth keeping in mind: the ci.yml run you see on the same commit is still a separate workflow and still gates nothing. The gate is the rust and python jobs inside the publish run. A release commit therefore runs the suite twice, in parallel. Run just ci locally anyway β€” it is faster than discovering it in CI.

πŸ”—3. Two workflows race to create the same GitHub Release

Both publish.yml:33-42 (with artifact-pattern: wheels-*) and nix.yml:55-63 (with artifact-pattern: nix-build-results) invoke github-release.yml for the same tag. softprops/action-gh-release creates-or-updates, so whichever finishes second updates the release β€” the final asset set depends on race ordering, and release notes may be regenerated twice. This is the likely cause of any "the release is missing the wheels" or "missing the nix binary" flakiness.

πŸ”—4. The dist/ + --allow-dirty knot

Read commits 49c24ca and d423ff8 together:

  • dist/ is gitignored, so cargo package was omitting the built frontend, and consumers building with --features server hit build.rs's exit(1). Fix: Cargo.toml gained include = [... "dist/**/*"].
  • That made things worse for CI: it downloads frontend-dist into dist/, which is now both gitignored and in Cargo's include, and cargo refuses to package files it will ship but that are not committed. Fix: publish-cargo.yml:29 gained --allow-dirty.
  • The same commit added a CARGO_DOC branch to build.rs (:12, :30-40) writing placeholder dist/ assets instead of exit(1), which is what unblocked docs.yml.

If you touch Cargo.toml's include, build.rs, or publish-cargo.yml, re-read all three together.

πŸ”—5. The version lives in exactly one place

Fixed 2026-07-28. Cargo.toml is the source; pyproject.toml is dynamic = ["version"] and flake.nix reads builtins.fromTOML ./Cargo.toml. just check-versions asserts no literal has crept back and runs in CI's lint job.

Why it mattered: Python's __version__ comes from CARGO_PKG_VERSION, not pyproject.toml, so a desync shipped a wheel whose metadata disagreed with expman.__version__ β€” a discrepancy nothing would have caught.

Still never hand-edit a version. Always just bump.

πŸ”—6. Never commit wrappers/python/expman/bin/

13 MB, platform-specific, and it must stay untracked-but-not-gitignored so maturin still ships it. See setup.

πŸ”—7. docs.yml uses force_orphan: true

Every push to main rewrites gh-pages history from scratch (docs.yml:33).

πŸ”—Pre-flight checklist

just ci                      # nothing else gates this
git status                   # bin/ untracked? debris staged?
git log --oneline -1         # will the bump commit be LAST on the push?
just bump patch
git push

Then watch: publish.yml, nix.yml, docs.yml, ci.yml all running; the release at v<version> should end up with both the wheels and the nix results attached.

© 2026 expman-rs