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:
| feature | pulls in | gives you |
cli | clap, comfy-table, indicatif, tboard, tensorboard-rs | the exp binary (src/main.rs, src/cli/) |
server | axum, tower-http, rust-embed, mime_guess, tokio-util, futures-util | src/api/ and the embedded frontend |
python | pyo3 (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.
Task-local state at engine.rs:230-235. Three independent flush triggers:
vector_buffer.len() >= flush_interval_rows (default 50)
- a
tokio::time::interval every flush_interval_ms (default 500 ms),
which also rewrites run.yaml with the current scalar/vector summary
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.
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.
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:
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.
- Reverted the previous commit's
prep-dist change β touch dist/index.html
is back (Justfile:37-38).
build.rs gained a CARGO_DOC branch (:12, :30-40) writing placeholder
dist/ assets instead of exit(1), unblocking docs.yml.
- 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.
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.
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:
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.
- 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.
grpc.local_channel_credentials() has no tonic equivalent.
- 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.
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.
| arg | default |
DIR | ./experiments |
--host | 127.0.0.1 |
--port, -p | 8000 |
--no-live | off |
--read-only | off |
--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):
--notebook-template <PATH>
<DIR>/.expman/notebook.ipynb β the convention, so a store can carry its own
notebook and the flag can stay unset
- 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:
| placeholder | value |
{{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 disk | what happens |
| absent | written |
matches content_hash, template_hash unchanged | left alone, silently |
matches content_hash, template_hash differs | rewritten, logged at info |
does not match content_hash β you edited it | left alone, logged at warn |
no metadata.expman β hand-made, or pre-1.3.0 | left 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]
| arg | default |
DIR | ./experiments |
--experiment, -e | none |
--project, -p | none |
--group, -g | none |
--tag, -t | none |
--status, -s | none |
--runs | off |
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.
| subcommand | what |
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.
| subcommand | what |
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.
| host | probe | result |
| 2Γ RTX A6000 | nvidia-smi | 6 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-info | tpu.N.tensorcore_utilization; HBM and duty cycle appear once a framework opens the TPU |
| any Linux | /proc | cpu.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]
| arg | default |
DIR | ./experiments |
--older-than | 1h (90s, 30m, 2h, 3d; bare number = seconds) |
--project, -p | none |
--experiment, -e | none |
--force | off (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]
| arg | default |
EXPERIMENT | optional |
--dir | ./experiments |
--project, -p | none |
--group, -g | none |
--tag, -t | none |
--keep, -k | 5 |
--force | off |
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>
| arg | default |
RUN_DIR | β |
--format, -f | csv β one of csv, json, tensorboard |
--output, -o | stdout 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.
| arg | default |
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.
| Method | Path | Handler | Response |
| GET | /projects | projects.rs | [{id, display_name, description, tags, experiments_count, created_at, generated, generated_from, generated_at}] |
| POST | /projects | projects.rs:53 | ProjectMetadata |
| GET | /projects/{project} | projects.rs | {id, display_name, description, tags, created_at, generated, generated_from, generated_at, readme, experiments: [...]} |
| PATCH | /projects/{project} | projects.rs:151 | updated ProjectMetadata. Body: {display_name?, description?, tags?} |
| DELETE | /projects/{project} | projects.rs:178 | 204 No Content (unassigns experiments) |
| GET | /projects/{project}/runs | projects.rs | cross-experiment runs table with facets β see below |
| GET | /projects/{project}/readme | projects.rs | {content} |
| PUT | /projects/{project}/readme | projects.rs:133 | {content}. Body: {content} |
πExperiments
| Method | Path | Handler | Response |
| GET | /experiments | experiments.rs:17 | [{id, display_name, description, tags, project, runs_count}] (ad-hoc json!) |
| GET | /experiments/{exp}/metadata | experiments.rs:39 | ExperimentMetadata |
| PATCH | /experiments/{exp}/metadata | experiments.rs:60 | merged ExperimentMetadata. Body: {display_name?, description?, tags?, project?} |
| GET | /experiments/{exp}/stats | stats.rs:16 | [{run, status, started_at, finished_at, duration_secs, last_metrics}] |
| Method | Path | Handler | Notes |
| GET | /experiments/{exp}/runs | runs.rs:26 | query ?metrics=a,b,c filters columns. Backfills vectors from vectors.parquet when empty (runs.rs:56-68) |
| GET | /experiments/{exp}/runs/{run}/metadata | runs.rs:88 | RunMetadata with latest scalars merged into vectors |
| PATCH | /experiments/{exp}/runs/{run}/metadata | runs.rs:118 | Body: {name?, description?, tags?} |
| GET | /experiments/{exp}/runs/{run}/config | metrics.rs:118 | config.yaml as JSON |
πMetrics and streams
| Method | Path | Handler | Notes |
| GET | /experiments/{exp}/runs/{run}/metrics | metrics.rs:28 | query ?since_step=N. Returns Vec<HashMap<String, Value>> |
| GET (SSE) | /run/{exp}/{run}/stream/vectors | metrics.rs:41 | every 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/stream | metrics.rs:78 | query ?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
| Method | Path | Handler | Notes |
| GET | /experiments/{exp}/runs/{run}/artifacts | artifacts.rs:18 | Vec<ArtifactInfo> |
| GET | /experiments/{exp}/runs/{run}/artifacts/content | artifacts.rs:34 | query ?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). |
| Method | Path | Handler | Response |
| GET | /stats | stats.rs | {total_experiments, total_projects, total_runs, active_runs, stale_runs, total_storage_bytes} |
| GET | /config | stats.rs | {live_mode, read_only, version} β both read from AppState, so they reflect the flags exp serve was actually given |
Per-run, plus a __multi__ variant scoped to the experiment.
| Method | Path | Handler |
| GET | /jupyter/available | jupyter_handlers.rs available_jupyter β {backend: "jupyter"|"python"|"none"} |
| POST | /experiments/{exp}/runs/{run}/jupyter/start | start_jupyter β {port} |
| POST | /experiments/{exp}/runs/{run}/jupyter/stop | stop_jupyter |
| GET | /experiments/{exp}/runs/{run}/jupyter/status | status_jupyter β {running, port} |
| GET | /experiments/{exp}/runs/{run}/jupyter/notebook | get_jupyter_notebook β {exists, content} |
| POST | /experiments/{exp}/runs/{run}/jupyter/notebook | create_jupyter_notebook β {created, content} or 409 |
| POST | /experiments/{exp}/jupyter/start | start_multi_jupyter, body {runs: [String]} |
| POST | /experiments/{exp}/jupyter/stop | stop_multi_jupyter |
| GET | /experiments/{exp}/jupyter/status | status_multi_jupyter |
| GET | /experiments/{exp}/jupyter/notebook | get_multi_jupyter_notebook |
| POST | /experiments/{exp}/jupyter/notebook | create_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
| Method | Path | Handler |
| GET | /tensorboard/available | tensorboard_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.
- 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):
| param | meaning |
tags | expression, e.g. arm:tiered AND (study:1 OR study:2) |
status | RUNNING, FINISHED, FAILED, CRASHED (400 otherwise) |
experiment | narrow to one experiment |
group | narrow 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.
| param | meaning |
max_points | change the cap; 0 disables it |
full=1 | return every row β the escape hatch for export and analysis |
since_step | only 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.
| member | backing | notes |
log_params(params: dict) | PyO3 | merged into config.yaml. Types are preserved (bool/int/float stay distinct). |
log_vector(values: dict, step: int|None = None) | PyO3 | the hot path β a channel send. True and 1 both become Float(1.0). |
log_scalar(key: str, value) | PyO3 | replaces an existing value; lands in run.yaml.scalars. |
save_artifact(path: str) | PyO3 | copied under the run's artifacts/; relative paths preserved. |
info(message) / warn(message) | PyO3 | appended to run.log. |
set_project(project: str|None) | PyO3 | writes only project: in experiment.yaml; offline. None unassigns. |
project | PyO3 getter | reads it back from experiment.yaml. |
set_tags(tags) / add_tags(tags) | PyO3 | replace or union this run's tags. add_tags is idempotent per tag. |
set_description(text) | PyO3 | sets this run's description. |
group / rank | PyO3 getters | this run's place in its group, or None. |
is_primary | Python | True 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) | PyO3 | bytes, a path, a PIL Image, a matplotlib Figure, or an HWC/CHW numpy array. Raises on anything else. |
log_figure / log_audio / log_video | PyO3 | as above; audio/video take encoded bytes. |
log_histogram(tag, values=..., bins=64) | PyO3 | bins here (numpy if present, pure Python otherwise), or pass edges=/counts= precomputed. |
run_dir / run_name | PyO3 getters | raise RuntimeError("Engine is closed") after close β unlike the write methods, which silently no-op. |
tensorboard_dir | pure Python (uncommitted) | <run_dir>/tensorboard, makedirs(exist_ok=True). Intended for torch.profiler.tensorboard_trace_handler. |
close(status="FINISHED") | both | restores 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.
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.
| function | returns |
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.
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_dir | becomes |
"runs/exp1" | Experiment("exp1", base_dir="runs") |
"my_exp" (no separator) | Experiment("my_exp", base_dir="experiments") |
None | runs/<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.
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.
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.
| field | type | default |
name | String | β |
run_name | String | local timestamp |
base_dir | PathBuf | β |
flush_interval_rows | usize | 50 |
flush_interval_ms | u64 | 500 |
language | String | "rust" (PyO3 sets "python") |
env_path | Option<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
| file | what |
vectors.parquet | compacted metrics, written at close |
vectors-NNNN.arrow | live append-only segments; folded into the Parquet at close |
system.parquet / system-NNNN.arrow | sampled hardware metrics |
histograms.parquet / histograms-NNNN.arrow | one row per (tag, step): JSON edges, counts, total |
media/ + media.jsonl | logged images/audio/video and their manifest |
provenance.yaml | git commit/branch/dirty, command, hostname, scheduler ids |
run.yaml, config.yaml, run.log, console.log | as before |
.run.lock | advisory lock file for run.yaml updates |
interactive.ipynb | the 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.
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.
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.
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:
| column | type | nullable |
step | Int64 | yes |
timestamp | Timestamp(Microsecond, "UTC") | no |
| each metric key | Float64 or Utf8 | yes |
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
| function | line | behavior |
read_vectors(path) | :303 | all rows as Vec<HashMap<String, serde_json::Value>>; vec![] if the file is absent |
read_vectors_since(path, since) | :312 | reads 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) | :334 | reads 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
| file | gate | what |
src/lib.rs | β | 18 lines of module declarations; crate docs are include_str!("../README.md") |
src/main.rs | cli | the exp binary: init_tracing() then run_cli() |
src/app/main.rs | wasm32 | the 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
| file | gate | what |
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.rs | not wasm32 | LoggingEngine, LogLevel, LogCommand, background_task |
storage.rs | not wasm32 | Parquet 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.rs | not wasm32 | one-way project sync from a YAML manifest: ProjectManifest, sync_project, README rendering, the generated marker |
sweep.rs | not wasm32 | sweep expansion (grid/random, seeded SplitMix64), trial command/env rendering, sbatch emission |
sysmetrics.rs | not wasm32 | subprocess probes for GPU/CPU/RAM: ProbeSpec, SystemSampler, the nvidia-CSV and generic-JSON parsers |
provenance.rs | not wasm32 | git/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
| file | what |
mod.rs | router, CORS, serve(), graceful shutdown |
state.rs | AppState (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.rs | handlers β see http-api |
frontend.rs | rust-embed of dist/, SPA fallback |
jupyter_service.rs | 1331 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.rs | per-run and __multi__ handlers; builds the NotebookContext, reading {{project}} from the experiment's experiment.yaml |
tensorboard_service.rs | untracked. 175 lines, near-verbatim clone of the Jupyter manager. Port scan 6006β7999. No tests. |
tensorboard_handlers.rs | untracked. 5 handlers. |
πsrc/app/ β wasm32 only (Leptos 0.8 CSR)
| file | what |
mod.rs | App, sidebar shell, routes |
main.rs | wasm entry |
models.rs | thin re-export of core::dto (since 2026-07-28). Previously hand-mirrored JSON types, which drifted; add fields to core::dto instead |
fetch.rs | 411 lines of gloo_net calls returning Result<T, String> |
utils.rs | SidebarContext, 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.rs | the 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.rs | fallback |
components/charts.rs | ~1013 lines: MetricsView, LineChart, ScalarChart. Hand-rolled pan/zoom over plotters-canvas. |
components/runs_table.rs | table + CSV/LaTeX/Typst export via blob download |
components/artifacts.rs | artifact browser, tabular preview |
components/console.rs | live log via raw web_sys::EventSource |
components/interactive.rs | Jupyter tab β iframes http://localhost:{port} |
components/tensorboard.rs | untracked. TensorBoard tab, single-run only. |
components/zoom.rs | shared 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/
| path | what |
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 |
Justfile | every build/test/lint/release command |
flake.nix | devShell + 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.
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
| command | what |
just build | frontend + python extension + cargo build --all-features |
just build-release | same, release profile |
just build-frontend | trunk build --release β dist/ |
just build-py | build the CLI, copy it into the package, maturin develop --release |
just dev-py | same but debug β the normal Python dev loop |
just build-docs | cargo doc --no-deps --all-features + a redirect index.html |
just check | fmt-check + lint-rust + lint-py + cargo check --all-features |
just clean | cargo clean and remove built .sos |
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 want | features |
| 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.
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.
Three type roles, self-hosted:
| role | face | carries |
| display | Space Grotesk | headings, the wordmark |
| body | Nunito | prose, descriptions, list items |
| mono | Cascadia Code | run 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
| symptom | cause |
| build script exits 1 with a trunk error | server feature on, no trunk. Set EXPMAN_SKIP_FRONTEND_BUILD=1 or just prep-dist. |
| dashboard loads unstyled | Tailwind is loaded from a CDN (src/app/index.html:8). You are offline. |
| Jupyter/TensorBoard tab shows a blank iframe | those 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 expman | the extension is not built. just dev-py. |
| Interactive tab cannot import your project's package | the 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 tab | that 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 CI | just 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.
| command | what |
just test | test-py then cargo nextest run --all-features |
just test-release | cargo nextest run --all-features --no-capture β what CI runs |
just test-py | cd wrappers/python && uv run --extra dev pytest tests |
just test-watch | cargo watch -x 'nextest run --workspace' |
just lint | lint-rust + lint-py |
just lint-rust | lint-frontend first, then cargo clippy --all-features --all-targets -- -D warnings |
just lint-frontend | clippy against --target wasm32-unknown-unknown |
just fmt / just fmt-check | cargo fmt --all |
just ci | fmt-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
π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:
- reads the current version from
Cargo.toml
- rewrites it there and nowhere else β
pyproject.toml takes it via
maturin's dynamic = ["version"], and flake.nix reads it with
builtins.fromTOML
cargo update -p expman to refresh Cargo.lock
- runs
just check-versions, which fails if a literal has been reintroduced
- 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.
| target | workflow | secret / environment |
| crates.io | publish-cargo.yml | env CARGO, CARGO_REGISTRY_TOKEN |
| PyPI | publish-pypi.yml (3-OS matrix β uv publish) | env PYPI, PYPI_API_TOKEN |
| GitHub Releases | github-release.yml | β |
| gh-pages (rustdoc) | docs.yml, on every push to main | β |
| Cachix | nix.yml | lokeshmohanty 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.